Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb8b64e46 | ||
|
|
d95a85435b | ||
|
|
962e2877e3 | ||
|
|
3459fe3fa4 | ||
|
|
6580f3100a | ||
|
|
15ffa98ea8 | ||
|
|
4c3b66a26b | ||
|
|
94e97a7366 | ||
|
|
0e95c125d3 | ||
|
|
fa2cb457fa | ||
|
|
02680aed29 | ||
|
|
63459a650d | ||
|
|
343989474d | ||
|
|
a0a6a11838 | ||
|
|
695232c711 | ||
|
|
5b11a6bad8 | ||
|
|
67ac02ea1a | ||
|
|
efa924cb82 | ||
|
|
ceef5c7bb9 | ||
|
|
58e1fb3cea | ||
|
|
546d7290ee | ||
|
|
a5b67b183b | ||
|
|
8efff60025 | ||
|
|
ec9bdf54ba | ||
|
|
0555e4c0dd | ||
|
|
08e0c82f4e | ||
|
|
e3fccca0fc | ||
|
|
7454799971 | ||
|
|
892b3ae5df | ||
|
|
bb52d5a077 | ||
|
|
1d8c8685f5 | ||
|
|
68981c98d5 | ||
|
|
ff1ce89ecb | ||
|
|
cde534735f | ||
|
|
5b45293b4d | ||
|
|
53b5074f35 | ||
|
|
aaa09b4745 | ||
|
|
24afce49a3 | ||
|
|
9d83697d45 | ||
|
|
c12c96cfb9 | ||
|
|
7a550bbd61 | ||
|
|
99b639e6d8 | ||
|
|
f0f22fde83 | ||
|
|
9649f63474 | ||
|
|
e63958e5eb | ||
|
|
ba5730287f | ||
|
|
4faa74b4c5 | ||
|
|
aa9e155b0c | ||
|
|
d819cd79fe | ||
|
|
c618fef2ef | ||
|
|
6265ffeab5 | ||
|
|
608a7ac78c | ||
|
|
ff1ab35b46 | ||
|
|
e8be9e7214 | ||
|
|
cfcd09611e | ||
|
|
4ed3e42148 | ||
|
|
a09f331d5f | ||
|
|
36beb427c0 | ||
|
|
ca2cbd5a7f | ||
|
|
d6803b99a6 | ||
|
|
8b630c9201 | ||
|
|
5eaafbde48 | ||
|
|
92f78c7008 | ||
|
|
d932f5a499 | ||
|
|
39a572a393 | ||
|
|
81fbd440ce | ||
|
|
6a84da5e85 | ||
|
|
0d40ced2f8 | ||
|
|
37d7aea258 | ||
|
|
e3f10b8b6e |
10
.env.example
10
.env.example
@@ -16,3 +16,13 @@ ALLOWED_SCRIPT_PATHS="scripts/"
|
|||||||
|
|
||||||
# WebSocket Configuration
|
# WebSocket Configuration
|
||||||
WEBSOCKET_PORT="3001"
|
WEBSOCKET_PORT="3001"
|
||||||
|
|
||||||
|
# User settings
|
||||||
|
GITHUB_TOKEN=
|
||||||
|
SAVE_FILTER=false
|
||||||
|
FILTERS=
|
||||||
|
AUTH_USERNAME=
|
||||||
|
AUTH_PASSWORD_HASH=
|
||||||
|
AUTH_ENABLED=false
|
||||||
|
AUTH_SETUP_COMPLETED=false
|
||||||
|
JWT_SECRET=
|
||||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -11,5 +11,6 @@
|
|||||||
|
|
||||||
|
|
||||||
# Set default reviewers
|
# Set default reviewers
|
||||||
|
* @michelroegl-brunner
|
||||||
* @community-scripts/Contributor
|
* @community-scripts/Contributor
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,6 +16,9 @@
|
|||||||
db.sqlite
|
db.sqlite
|
||||||
data/settings.db
|
data/settings.db
|
||||||
|
|
||||||
|
# ssh keys (sensitive)
|
||||||
|
data/ssh-keys/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|||||||
243
README.md
243
README.md
@@ -210,6 +210,249 @@ The application uses SQLite for storing server configurations:
|
|||||||
- **Backup**: Copy `data/settings.db` to backup your server configurations
|
- **Backup**: Copy `data/settings.db` to backup your server configurations
|
||||||
- **Reset**: Delete `data/settings.db` to reset all server configurations
|
- **Reset**: Delete `data/settings.db` to reset all server configurations
|
||||||
|
|
||||||
|
## 📖 Feature Guide
|
||||||
|
|
||||||
|
This section provides detailed information about the application's key features and how to use them effectively.
|
||||||
|
|
||||||
|
### Server Settings
|
||||||
|
|
||||||
|
Manage your Proxmox VE servers and configure connection settings.
|
||||||
|
|
||||||
|
**Adding PVE Servers:**
|
||||||
|
- **Server Name**: A friendly name to identify your server
|
||||||
|
- **IP Address**: The IP address or hostname of your PVE server
|
||||||
|
- **Username**: PVE user account (usually root or a dedicated user)
|
||||||
|
- **SSH Port**: Default is 22, change if your server uses a different port
|
||||||
|
|
||||||
|
**Authentication Types:**
|
||||||
|
- **Password**: Use username and password authentication
|
||||||
|
- **SSH Key**: Use SSH key pair for secure authentication
|
||||||
|
- **Both**: Try SSH key first, fallback to password if needed
|
||||||
|
|
||||||
|
**Server Color Coding:**
|
||||||
|
Assign colors to servers for visual distinction throughout the application. This helps identify which server you're working with when managing scripts. This needs to be enabled in the General Settings.
|
||||||
|
|
||||||
|
### General Settings
|
||||||
|
|
||||||
|
Configure application preferences and behavior.
|
||||||
|
|
||||||
|
**Save Filters:**
|
||||||
|
When enabled, your script filter preferences (search terms, categories, sorting) will be automatically saved and restored when you return to the application:
|
||||||
|
- Search queries are preserved
|
||||||
|
- Selected script types are remembered
|
||||||
|
- Sort preferences are maintained
|
||||||
|
- Category selections are saved
|
||||||
|
|
||||||
|
**Server Color Coding:**
|
||||||
|
Enable visual color coding for servers throughout the application. This makes it easier to identify which server you're working with.
|
||||||
|
|
||||||
|
**GitHub Integration:**
|
||||||
|
Add a GitHub Personal Access Token to increase API rate limits and improve performance:
|
||||||
|
- Bypasses GitHub's rate limiting for unauthenticated requests
|
||||||
|
- Improves script loading and syncing performance
|
||||||
|
- Token is stored securely and only used for API calls
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
Secure your application with username and password authentication:
|
||||||
|
- Set up username and password for app access
|
||||||
|
- Enable/disable authentication as needed
|
||||||
|
- Credentials are stored securely
|
||||||
|
|
||||||
|
### Sync Button
|
||||||
|
|
||||||
|
Synchronize script metadata from the ProxmoxVE GitHub repository.
|
||||||
|
|
||||||
|
**What Does Syncing Do?**
|
||||||
|
- **Updates Script Metadata**: Downloads the latest script information (JSON files)
|
||||||
|
- **Refreshes Available Scripts**: Updates the list of scripts you can download
|
||||||
|
- **Updates Categories**: Refreshes script categories and organization
|
||||||
|
- **Checks for Updates**: Identifies which downloaded scripts have newer versions
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- **Metadata Only**: Syncing only updates script information, not the actual script files
|
||||||
|
- **No Downloads**: Script files are downloaded separately when you choose to install them
|
||||||
|
- **Last Sync Time**: Shows when the last successful sync occurred
|
||||||
|
- **Rate Limits**: GitHub API limits may apply without a personal access token
|
||||||
|
|
||||||
|
**When to Sync:**
|
||||||
|
- When you want to see the latest available scripts
|
||||||
|
- To check for updates to your downloaded scripts
|
||||||
|
- If you notice scripts are missing or outdated
|
||||||
|
- After the ProxmoxVE repository has been updated
|
||||||
|
|
||||||
|
### Available Scripts
|
||||||
|
|
||||||
|
Browse and discover scripts from the ProxmoxVE repository.
|
||||||
|
|
||||||
|
**Browsing Scripts:**
|
||||||
|
- **Category Sidebar**: Filter scripts by category (Storage, Network, Security, etc.)
|
||||||
|
- **Search**: Find scripts by name or description
|
||||||
|
- **View Modes**: Switch between card and list view
|
||||||
|
- **Sorting**: Sort by name or creation date
|
||||||
|
|
||||||
|
**Filtering Options:**
|
||||||
|
- **Script Types**: Filter by CT (Container) or other script types
|
||||||
|
- **Update Status**: Show only scripts with available updates
|
||||||
|
- **Search Query**: Search within script names and descriptions
|
||||||
|
- **Categories**: Filter by specific script categories
|
||||||
|
|
||||||
|
**Script Actions:**
|
||||||
|
- **View Details**: Click on a script to see full information and documentation
|
||||||
|
- **Download**: Download script files to your local system
|
||||||
|
- **Install**: Run scripts directly on your PVE servers
|
||||||
|
- **Preview**: View script content before downloading
|
||||||
|
|
||||||
|
### Downloaded Scripts
|
||||||
|
|
||||||
|
Manage scripts that have been downloaded to your local system.
|
||||||
|
|
||||||
|
**What Are Downloaded Scripts?**
|
||||||
|
These are scripts that you've downloaded from the repository and are stored locally on your system:
|
||||||
|
- Script files are stored in your local scripts directory
|
||||||
|
- You can run these scripts on your PVE servers
|
||||||
|
- Scripts can be updated when newer versions are available
|
||||||
|
|
||||||
|
**Update Detection:**
|
||||||
|
The system automatically checks if newer versions of your downloaded scripts are available:
|
||||||
|
- Scripts with updates available are marked with an update indicator
|
||||||
|
- You can filter to show only scripts with available updates
|
||||||
|
- Update detection happens when you sync with the repository
|
||||||
|
|
||||||
|
**Managing Downloaded Scripts:**
|
||||||
|
- **Update Scripts**: Download the latest version of a script
|
||||||
|
- **View Details**: See script information and documentation
|
||||||
|
- **Install/Run**: Execute scripts on your PVE servers
|
||||||
|
- **Filter & Search**: Use the same filtering options as Available Scripts
|
||||||
|
|
||||||
|
### Installed Scripts
|
||||||
|
|
||||||
|
Track and manage scripts that are installed on your PVE servers.
|
||||||
|
|
||||||
|
**Auto-Detection (Primary Feature):**
|
||||||
|
The system can automatically detect LXC containers that have community-script tags on your PVE servers:
|
||||||
|
- **Automatic Discovery**: Scans your PVE servers for containers with community-script tags
|
||||||
|
- **Container Detection**: Identifies LXC containers running Proxmox helper scripts
|
||||||
|
- **Server Association**: Links detected scripts to the specific PVE server
|
||||||
|
- **Bulk Import**: Automatically creates records for all detected scripts
|
||||||
|
|
||||||
|
**How Auto-Detection Works:**
|
||||||
|
1. Connects to your configured PVE servers
|
||||||
|
2. Scans LXC container configurations
|
||||||
|
3. Looks for containers with community-script tags
|
||||||
|
4. Creates installed script records automatically
|
||||||
|
|
||||||
|
**Manual Script Management:**
|
||||||
|
- **Add Scripts Manually**: Create records for scripts not auto-detected
|
||||||
|
- **Edit Script Details**: Update script names and container IDs
|
||||||
|
- **Delete Scripts**: Remove scripts from tracking
|
||||||
|
- **Bulk Operations**: Clean up old or invalid script records
|
||||||
|
|
||||||
|
**Script Tracking Features:**
|
||||||
|
- **Installation Status**: Track success, failure, or in-progress installations
|
||||||
|
- **Server Association**: Know which server each script is installed on
|
||||||
|
- **Container ID**: Link scripts to specific LXC containers
|
||||||
|
- **Web UI Access**: Track and access Web UI IP addresses and ports
|
||||||
|
- **Execution Logs**: View output and logs from script installations
|
||||||
|
- **Filtering**: Filter by server, status, or search terms
|
||||||
|
|
||||||
|
**Managing Installed Scripts:**
|
||||||
|
- **View All Scripts**: See all tracked scripts across all servers
|
||||||
|
- **Filter by Server**: Show scripts for a specific PVE server
|
||||||
|
- **Filter by Status**: Show successful, failed, or in-progress installations
|
||||||
|
- **Sort Options**: Sort by name, container ID, server, status, or date
|
||||||
|
- **Update Scripts**: Re-run or update existing script installations
|
||||||
|
|
||||||
|
**Web UI Access:**
|
||||||
|
Automatically detect and access Web UI interfaces for your installed scripts:
|
||||||
|
- **Auto-Detection**: Automatically detects Web UI URLs from script installation output
|
||||||
|
- **IP & Port Tracking**: Stores and displays Web UI IP addresses and ports
|
||||||
|
- **One-Click Access**: Click IP:port to open Web UI in new tab
|
||||||
|
- **Manual Detection**: Re-detect IP using `hostname -I` inside container
|
||||||
|
- **Port Detection**: Uses script metadata to get correct port (e.g., actualbudget:5006)
|
||||||
|
- **Editable Fields**: Manually edit IP and port values as needed
|
||||||
|
|
||||||
|
**Actions Dropdown:**
|
||||||
|
Clean interface with all actions organized in a dropdown menu:
|
||||||
|
- **Edit Button**: Always visible for quick script editing
|
||||||
|
- **Actions Dropdown**: Contains Update, Shell, Open UI, Start/Stop, Destroy, Delete
|
||||||
|
- **Smart Visibility**: Dropdown only appears when actions are available
|
||||||
|
- **Auto-Close**: Dropdown closes after clicking any action
|
||||||
|
- **Disabled States**: Actions are disabled when container is stopped
|
||||||
|
|
||||||
|
**Container Control:**
|
||||||
|
Directly control LXC containers from the installed scripts page via SSH:
|
||||||
|
- **Start/Stop Button**: Control container state with `pct start/stop <ID>`
|
||||||
|
- **Container Status**: Real-time status indicator (running/stopped/unknown)
|
||||||
|
- **Destroy Button**: Permanently remove LXC container with `pct destroy <ID>`
|
||||||
|
- **Confirmation Modals**: Simple OK/Cancel for start/stop, type container ID to confirm destroy
|
||||||
|
- **SSH Execution**: All commands executed remotely via configured SSH connections
|
||||||
|
|
||||||
|
**Safety Features:**
|
||||||
|
- Start/Stop actions require simple confirmation
|
||||||
|
- Destroy action requires typing the container ID to confirm
|
||||||
|
- All actions show loading states and error handling
|
||||||
|
- Only works with SSH scripts that have valid container IDs
|
||||||
|
|
||||||
|
### Update System
|
||||||
|
|
||||||
|
Keep your PVE Scripts Management application up to date with the latest features and improvements.
|
||||||
|
|
||||||
|
**What Does Updating Do?**
|
||||||
|
- **Downloads Latest Version**: Fetches the newest release from the GitHub repository
|
||||||
|
- **Updates Application Files**: Replaces current files with the latest version
|
||||||
|
- **Installs Dependencies**: Updates Node.js packages and dependencies
|
||||||
|
- **Rebuilds Application**: Compiles the application with latest changes
|
||||||
|
- **Restarts Server**: Automatically restarts the application server
|
||||||
|
|
||||||
|
**How to Update:**
|
||||||
|
|
||||||
|
**Automatic Update (Recommended):**
|
||||||
|
- Click the "Update Now" button when an update is available
|
||||||
|
- The system will handle everything automatically
|
||||||
|
- You'll see a progress overlay with update logs
|
||||||
|
- The page will reload automatically when complete
|
||||||
|
|
||||||
|
**Manual Update (Advanced):**
|
||||||
|
If automatic update fails, you can update manually:
|
||||||
|
```bash
|
||||||
|
# Navigate to the application directory
|
||||||
|
cd $PVESCRIPTLOCAL_DIR
|
||||||
|
|
||||||
|
# Pull latest changes
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update Process:**
|
||||||
|
1. **Check for Updates**: System automatically checks GitHub for new releases
|
||||||
|
2. **Download Update**: Downloads the latest release files
|
||||||
|
3. **Backup Current Version**: Creates backup of current installation
|
||||||
|
4. **Install New Version**: Replaces files and updates dependencies
|
||||||
|
5. **Build Application**: Compiles the updated code
|
||||||
|
6. **Restart Server**: Stops old server and starts new version
|
||||||
|
7. **Reload Page**: Automatically refreshes the browser
|
||||||
|
|
||||||
|
**Release Notes:**
|
||||||
|
Click the external link icon next to the update button to view detailed release notes on GitHub:
|
||||||
|
- See what's new in each version
|
||||||
|
- Read about bug fixes and improvements
|
||||||
|
- Check for any breaking changes
|
||||||
|
- View installation requirements
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- **Backup**: Your data and settings are preserved during updates
|
||||||
|
- **Downtime**: Brief downtime occurs during the update process
|
||||||
|
- **Compatibility**: Updates maintain backward compatibility with your data
|
||||||
|
- **Rollback**: If issues occur, you can manually revert to previous version
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -18,6 +18,40 @@ const config = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// Allow cross-origin requests from local network ranges
|
||||||
|
allowedDevOrigins: [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://127.0.0.1:3000',
|
||||||
|
'http://[::1]:3000',
|
||||||
|
'http://10.*',
|
||||||
|
'http://172.16.*',
|
||||||
|
'http://172.17.*',
|
||||||
|
'http://172.18.*',
|
||||||
|
'http://172.19.*',
|
||||||
|
'http://172.20.*',
|
||||||
|
'http://172.21.*',
|
||||||
|
'http://172.22.*',
|
||||||
|
'http://172.23.*',
|
||||||
|
'http://172.24.*',
|
||||||
|
'http://172.25.*',
|
||||||
|
'http://172.26.*',
|
||||||
|
'http://172.27.*',
|
||||||
|
'http://172.28.*',
|
||||||
|
'http://172.29.*',
|
||||||
|
'http://172.30.*',
|
||||||
|
'http://172.31.*',
|
||||||
|
'http://192.168.*',
|
||||||
|
],
|
||||||
|
|
||||||
|
webpack: (config, { dev, isServer }) => {
|
||||||
|
if (dev && !isServer) {
|
||||||
|
config.watchOptions = {
|
||||||
|
poll: 1000,
|
||||||
|
aggregateTimeout: 300,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
2745
package-lock.json
generated
2745
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -22,9 +22,11 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@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",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"@trpc/client": "^11.6.0",
|
"@trpc/client": "^11.6.0",
|
||||||
"@trpc/react-query": "^11.6.0",
|
"@trpc/react-query": "^11.6.0",
|
||||||
"@trpc/server": "^11.6.0",
|
"@trpc/server": "^11.6.0",
|
||||||
@@ -33,22 +35,26 @@
|
|||||||
"@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",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.5",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
"refractor": "^5.0.0",
|
"refractor": "^5.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"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",
|
"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",
|
||||||
@@ -56,22 +62,24 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
"@types/node": "^24.3.1",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^24.7.2",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^3.2.4",
|
||||||
"eslint": "^9.23.0",
|
"eslint": "^9.23.0",
|
||||||
"eslint-config-next": "^15.5.4",
|
"eslint-config-next": "^15.5.5",
|
||||||
"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.7.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"typescript-eslint": "^8.27.0",
|
"typescript-eslint": "^8.46.1",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "OpenWrt",
|
|
||||||
"slug": "openwrt",
|
|
||||||
"categories": [
|
|
||||||
4,
|
|
||||||
2
|
|
||||||
],
|
|
||||||
"date_created": "2024-05-02",
|
|
||||||
"type": "vm",
|
|
||||||
"updateable": true,
|
|
||||||
"privileged": false,
|
|
||||||
"interface_port": null,
|
|
||||||
"documentation": "https://openwrt.org/docs/start",
|
|
||||||
"website": "https://openwrt.org/",
|
|
||||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/openwrt.webp",
|
|
||||||
"config_path": "",
|
|
||||||
"description": "OpenWrt is a powerful open-source firmware that can transform a wide range of networking devices into highly customizable and feature-rich routers, providing users with greater control and flexibility over their network infrastructure.",
|
|
||||||
"install_methods": [
|
|
||||||
{
|
|
||||||
"type": "default",
|
|
||||||
"script": "vm/openwrt.sh",
|
|
||||||
"resources": {
|
|
||||||
"cpu": 1,
|
|
||||||
"ram": 256,
|
|
||||||
"hdd": 0.5,
|
|
||||||
"os": null,
|
|
||||||
"version": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"default_credentials": {
|
|
||||||
"username": null,
|
|
||||||
"password": null
|
|
||||||
},
|
|
||||||
"notes": [
|
|
||||||
{
|
|
||||||
"text": "If you use VLANs (default LAN is set to VLAN 999), make sure the Proxmox Linux Bridge is configured as VLAN-aware, otherwise the VM may fail to start.",
|
|
||||||
"type": "info"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Petio",
|
|
||||||
"slug": "petio",
|
|
||||||
"categories": [
|
|
||||||
13
|
|
||||||
],
|
|
||||||
"date_created": "2024-06-12",
|
|
||||||
"type": "ct",
|
|
||||||
"updateable": true,
|
|
||||||
"privileged": false,
|
|
||||||
"interface_port": 7777,
|
|
||||||
"documentation": "https://docs.petio.tv/",
|
|
||||||
"website": "https://petio.tv/",
|
|
||||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/petio.webp",
|
|
||||||
"config_path": "",
|
|
||||||
"description": "Petio is a third party companion app available to Plex server owners to allow their users to request, review and discover content.",
|
|
||||||
"install_methods": [
|
|
||||||
{
|
|
||||||
"type": "default",
|
|
||||||
"script": "ct/petio.sh",
|
|
||||||
"resources": {
|
|
||||||
"cpu": 2,
|
|
||||||
"ram": 1024,
|
|
||||||
"hdd": 4,
|
|
||||||
"os": "ubuntu",
|
|
||||||
"version": "20.04"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"default_credentials": {
|
|
||||||
"username": null,
|
|
||||||
"password": null
|
|
||||||
},
|
|
||||||
"notes": []
|
|
||||||
}
|
|
||||||
217
server.js
217
server.js
@@ -51,6 +51,7 @@ const handle = app.getRequestHandler();
|
|||||||
* @property {string} [mode]
|
* @property {string} [mode]
|
||||||
* @property {ServerInfo} [server]
|
* @property {ServerInfo} [server]
|
||||||
* @property {boolean} [isUpdate]
|
* @property {boolean} [isUpdate]
|
||||||
|
* @property {boolean} [isShell]
|
||||||
* @property {string} [containerId]
|
* @property {string} [containerId]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -130,6 +131,55 @@ class ScriptExecutionHandler {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Web UI URL from terminal output
|
||||||
|
* @param {string} output - Terminal output to parse
|
||||||
|
* @returns {{ip: string, port: number}|null} - Object with ip and port if found, null otherwise
|
||||||
|
*/
|
||||||
|
parseWebUIUrl(output) {
|
||||||
|
// First, strip ANSI color codes to make pattern matching more reliable
|
||||||
|
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||||
|
|
||||||
|
// Look for URL patterns with any valid IP address (private or public)
|
||||||
|
const patterns = [
|
||||||
|
// HTTP/HTTPS URLs with IP and port
|
||||||
|
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)/gi,
|
||||||
|
// URLs without explicit port (assume default ports)
|
||||||
|
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/|$|\s)/gi,
|
||||||
|
// URLs with trailing slash and port
|
||||||
|
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)\//gi,
|
||||||
|
// URLs with just IP and port (no protocol)
|
||||||
|
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)(?:\s|$)/gi,
|
||||||
|
// URLs with just IP (no protocol, no port)
|
||||||
|
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\s|$)/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Try patterns on both original and cleaned output
|
||||||
|
const outputsToTry = [output, cleanOutput];
|
||||||
|
|
||||||
|
for (const testOutput of outputsToTry) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const matches = [...testOutput.matchAll(pattern)];
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match[1]) {
|
||||||
|
const ip = match[1];
|
||||||
|
const port = match[2] || (match[0].startsWith('https') ? '443' : '80');
|
||||||
|
|
||||||
|
// Validate IP address format
|
||||||
|
if (ip.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
|
||||||
|
return {
|
||||||
|
ip: ip,
|
||||||
|
port: parseInt(port, 10)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create installation record
|
* Create installation record
|
||||||
* @param {string} scriptName - Name of the script
|
* @param {string} scriptName - Name of the script
|
||||||
@@ -207,13 +257,15 @@ class ScriptExecutionHandler {
|
|||||||
* @param {WebSocketMessage} message
|
* @param {WebSocketMessage} message
|
||||||
*/
|
*/
|
||||||
async handleMessage(ws, message) {
|
async handleMessage(ws, message) {
|
||||||
const { action, scriptPath, executionId, input, mode, server, isUpdate, containerId } = message;
|
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
if (scriptPath && executionId) {
|
if (scriptPath && executionId) {
|
||||||
if (isUpdate && containerId) {
|
if (isUpdate && containerId) {
|
||||||
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
|
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
|
||||||
|
} else if (isShell && containerId) {
|
||||||
|
await this.startShellExecution(ws, containerId, executionId, mode, server);
|
||||||
} else {
|
} else {
|
||||||
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
|
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
|
||||||
}
|
}
|
||||||
@@ -361,6 +413,18 @@ class ScriptExecutionHandler {
|
|||||||
this.updateInstallationRecord(installationId, { container_id: containerId });
|
this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse for Web UI URL
|
||||||
|
const webUIUrl = this.parseWebUIUrl(output);
|
||||||
|
if (webUIUrl && installationId) {
|
||||||
|
const { ip, port } = webUIUrl;
|
||||||
|
if (ip && port) {
|
||||||
|
this.updateInstallationRecord(installationId, {
|
||||||
|
web_ui_ip: ip,
|
||||||
|
web_ui_port: port
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'output',
|
type: 'output',
|
||||||
data: output,
|
data: output,
|
||||||
@@ -444,6 +508,18 @@ class ScriptExecutionHandler {
|
|||||||
this.updateInstallationRecord(installationId, { container_id: containerId });
|
this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse for Web UI URL
|
||||||
|
const webUIUrl = this.parseWebUIUrl(data);
|
||||||
|
if (webUIUrl && installationId) {
|
||||||
|
const { ip, port } = webUIUrl;
|
||||||
|
if (ip && port) {
|
||||||
|
this.updateInstallationRecord(installationId, {
|
||||||
|
web_ui_ip: ip,
|
||||||
|
web_ui_port: port
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle data output
|
// Handle data output
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'output',
|
type: 'output',
|
||||||
@@ -709,6 +785,145 @@ class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start shell execution
|
||||||
|
* @param {ExtendedWebSocket} ws
|
||||||
|
* @param {string} containerId
|
||||||
|
* @param {string} executionId
|
||||||
|
* @param {string} mode
|
||||||
|
* @param {ServerInfo|null} server
|
||||||
|
*/
|
||||||
|
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Send start message
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'start',
|
||||||
|
data: `Starting shell session for container ${containerId}...`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === 'ssh' && server) {
|
||||||
|
await this.startSSHShellExecution(ws, containerId, executionId, server);
|
||||||
|
} else {
|
||||||
|
await this.startLocalShellExecution(ws, containerId, executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: `Failed to start shell: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start local shell execution
|
||||||
|
* @param {ExtendedWebSocket} ws
|
||||||
|
* @param {string} containerId
|
||||||
|
* @param {string} executionId
|
||||||
|
*/
|
||||||
|
async startLocalShellExecution(ws, containerId, executionId) {
|
||||||
|
const { spawn } = await import('node-pty');
|
||||||
|
|
||||||
|
// Create a shell process that will run pct enter
|
||||||
|
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
|
||||||
|
name: 'xterm-color',
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: process.env
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the execution
|
||||||
|
this.activeExecutions.set(executionId, {
|
||||||
|
process: childProcess,
|
||||||
|
ws
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle pty data
|
||||||
|
childProcess.onData((data) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: data.toString(),
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: No automatic command is sent - user can type commands interactively
|
||||||
|
|
||||||
|
// Handle process exit
|
||||||
|
childProcess.onExit((e) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'end',
|
||||||
|
data: `Shell session ended with exit code: ${e.exitCode}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeExecutions.delete(executionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start SSH shell execution
|
||||||
|
* @param {ExtendedWebSocket} ws
|
||||||
|
* @param {string} containerId
|
||||||
|
* @param {string} executionId
|
||||||
|
* @param {ServerInfo} server
|
||||||
|
*/
|
||||||
|
async startSSHShellExecution(ws, containerId, executionId, server) {
|
||||||
|
const sshService = getSSHExecutionService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const execution = await sshService.executeCommand(
|
||||||
|
server,
|
||||||
|
`pct enter ${containerId}`,
|
||||||
|
/** @param {string} data */
|
||||||
|
(data) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {string} error */
|
||||||
|
(error) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: error,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {number} code */
|
||||||
|
(code) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'end',
|
||||||
|
data: `Shell session ended with exit code: ${code}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeExecutions.delete(executionId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the execution
|
||||||
|
this.activeExecutions.set(executionId, {
|
||||||
|
process: /** @type {any} */ (execution).process,
|
||||||
|
ws
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: No automatic command is sent - user can type commands interactively
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: `SSH shell execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TerminalHandler removed - not used by current application
|
// TerminalHandler removed - not used by current application
|
||||||
|
|||||||
29
server.log
29
server.log
@@ -1,29 +0,0 @@
|
|||||||
|
|
||||||
> pve-scripts-local@0.1.0 dev
|
|
||||||
> node server.js
|
|
||||||
|
|
||||||
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
|
||||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
|
||||||
code: 'EADDRINUSE',
|
|
||||||
errno: -98,
|
|
||||||
syscall: 'listen',
|
|
||||||
address: '0.0.0.0',
|
|
||||||
port: 3000
|
|
||||||
}
|
|
||||||
⨯ uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
|
||||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
|
||||||
code: 'EADDRINUSE',
|
|
||||||
errno: -98,
|
|
||||||
syscall: 'listen',
|
|
||||||
address: '0.0.0.0',
|
|
||||||
port: 3000
|
|
||||||
}
|
|
||||||
⨯ uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
|
||||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
|
||||||
code: 'EADDRINUSE',
|
|
||||||
errno: -98,
|
|
||||||
syscall: 'listen',
|
|
||||||
address: '0.0.0.0',
|
|
||||||
port: 3000
|
|
||||||
}
|
|
||||||
Terminated
|
|
||||||
73
src/app/_components/AuthGuard.tsx
Normal file
73
src/app/_components/AuthGuard.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, type ReactNode } from 'react';
|
||||||
|
import { useAuth } from './AuthProvider';
|
||||||
|
import { AuthModal } from './AuthModal';
|
||||||
|
import { SetupModal } from './SetupModal';
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthConfig {
|
||||||
|
username: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
hasCredentials: boolean;
|
||||||
|
setupCompleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: AuthGuardProps) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
|
||||||
|
const [configLoading, setConfigLoading] = useState(true);
|
||||||
|
const [setupCompleted, setSetupCompleted] = useState(false);
|
||||||
|
|
||||||
|
const handleSetupComplete = async () => {
|
||||||
|
setSetupCompleted(true);
|
||||||
|
// Refresh auth config without reloading the page
|
||||||
|
await fetchAuthConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAuthConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/auth-credentials');
|
||||||
|
if (response.ok) {
|
||||||
|
const config = await response.json() as AuthConfig;
|
||||||
|
setAuthConfig(config);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching auth config:', error);
|
||||||
|
} finally {
|
||||||
|
setConfigLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchAuthConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show loading while checking auth status
|
||||||
|
if (isLoading || configLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-4"></div>
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show setup modal if setup has not been completed yet
|
||||||
|
if (authConfig && !authConfig.setupCompleted && !setupCompleted) {
|
||||||
|
return <SetupModal isOpen={true} onComplete={handleSetupComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show auth modal if auth is enabled but user is not authenticated
|
||||||
|
if (authConfig && authConfig.enabled && !isAuthenticated) {
|
||||||
|
return <AuthModal isOpen={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render children if authenticated or auth is disabled
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
111
src/app/_components/AuthModal.tsx
Normal file
111
src/app/_components/AuthModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { useAuth } from './AuthProvider';
|
||||||
|
import { Lock, User, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AuthModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthModal({ isOpen }: AuthModalProps) {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const success = await login(username, password);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setError('Invalid username or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Lock className="h-8 w-8 text-blue-600" />
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">Authentication Required</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-muted-foreground text-center mb-6">
|
||||||
|
Please enter your credentials to access the application.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-800 border border-red-200 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !username.trim() || !password.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing In...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/app/_components/AuthProvider.tsx
Normal file
119
src/app/_components/AuthProvider.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
username: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
// First check if setup is completed
|
||||||
|
const setupResponse = await fetch('/api/settings/auth-credentials');
|
||||||
|
if (setupResponse.ok) {
|
||||||
|
const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
|
||||||
|
|
||||||
|
// If setup is not completed or auth is disabled, don't verify
|
||||||
|
if (!setupData.setupCompleted || !setupData.enabled) {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only verify authentication if setup is completed and auth is enabled
|
||||||
|
const response = await fetch('/api/auth/verify');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { username: string };
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setUsername(data.username);
|
||||||
|
} else {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking auth:', error);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (username: string, password: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { username: string };
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setUsername(data.username);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('Login failed:', errorData.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
// Clear the auth cookie by setting it to expire
|
||||||
|
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUsername(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
isAuthenticated,
|
||||||
|
username,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
checkAuth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
|
|
||||||
interface CategorySidebarProps {
|
interface CategorySidebarProps {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
@@ -40,7 +41,7 @@ const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; c
|
|||||||
),
|
),
|
||||||
key: (
|
key: (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
archive: (
|
archive: (
|
||||||
@@ -196,14 +197,17 @@ export function CategorySidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
|
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
|
||||||
isCollapsed ? 'w-16' : 'w-80'
|
isCollapsed ? 'w-16' : 'w-full lg:w-80'
|
||||||
}`}>
|
}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div>
|
<div className="flex items-center justify-between w-full">
|
||||||
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
|
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
|
||||||
|
</div>
|
||||||
|
<ContextualHelpIcon section="available-scripts" tooltip="Help with categories" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -292,7 +296,7 @@ export function CategorySidebar({
|
|||||||
|
|
||||||
{/* Collapsed state - show only icons with counters and tooltips */}
|
{/* Collapsed state - show only icons with counters and tooltips */}
|
||||||
{isCollapsed && (
|
{isCollapsed && (
|
||||||
<div className="p-2 flex flex-col space-y-2">
|
<div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
|
||||||
{/* "All Categories" option */}
|
{/* "All Categories" option */}
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<button
|
<button
|
||||||
@@ -317,7 +321,7 @@ export function CategorySidebar({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* 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">
|
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
|
||||||
All Categories ({totalScripts})
|
All Categories ({totalScripts})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +354,7 @@ export function CategorySidebar({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* 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">
|
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
|
||||||
{category} ({count})
|
{category} ({count})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
125
src/app/_components/ColorCodedDropdown.tsx
Normal file
125
src/app/_components/ColorCodedDropdown.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import type { Server } from '../../types/server';
|
||||||
|
|
||||||
|
interface ColorCodedDropdownProps {
|
||||||
|
servers: Server[];
|
||||||
|
selectedServer: Server | null;
|
||||||
|
onServerSelect: (server: Server | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorCodedDropdown({
|
||||||
|
servers,
|
||||||
|
selectedServer,
|
||||||
|
onServerSelect,
|
||||||
|
placeholder = "Select a server...",
|
||||||
|
disabled = false
|
||||||
|
}: ColorCodedDropdownProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleServerClick = (server: Server) => {
|
||||||
|
onServerSelect(server);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
onServerSelect(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Dropdown Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary bg-background text-foreground text-left flex items-center justify-between ${
|
||||||
|
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedServer ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{selectedServer.color && (
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: selectedServer.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedServer.name} ({selectedServer.ip}) - {selectedServer.user}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
placeholder
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-card border border-border rounded-md shadow-lg max-h-60 overflow-auto">
|
||||||
|
{/* Clear Selection Option */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearSelection}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Server Options */}
|
||||||
|
{servers
|
||||||
|
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
||||||
|
.map((server) => (
|
||||||
|
<button
|
||||||
|
key={server.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleServerClick(server)}
|
||||||
|
className={`w-full px-3 py-2 text-left text-sm transition-colors flex items-center gap-2 ${
|
||||||
|
selectedServer?.id === server.id
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-foreground hover:bg-accent hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{server.color && (
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: server.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate">
|
||||||
|
{server.name} ({server.ip}) - {server.user}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/app/_components/ConfirmationModal.tsx
Normal file
111
src/app/_components/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { AlertTriangle, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfirmationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
variant: 'simple' | 'danger';
|
||||||
|
confirmText?: string; // What the user must type for danger variant
|
||||||
|
confirmButtonText?: string;
|
||||||
|
cancelButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmationModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
variant,
|
||||||
|
confirmText,
|
||||||
|
confirmButtonText = 'Confirm',
|
||||||
|
cancelButtonText = 'Cancel'
|
||||||
|
}: ConfirmationModalProps) {
|
||||||
|
const [typedText, setTypedText] = useState('');
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const isDanger = variant === 'danger';
|
||||||
|
const isConfirmEnabled = isDanger ? typedText === confirmText : true;
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (isConfirmEnabled) {
|
||||||
|
onConfirm();
|
||||||
|
setTypedText(''); // Reset for next time
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
setTypedText(''); // Reset when closing
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isDanger ? (
|
||||||
|
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||||
|
) : (
|
||||||
|
<Info className="h-8 w-8 text-blue-600" />
|
||||||
|
)}
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">{title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Type-to-confirm input for danger variant */}
|
||||||
|
{isDanger && confirmText && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Type <code className="bg-muted px-2 py-1 rounded text-sm">{confirmText}</code> to confirm:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={typedText}
|
||||||
|
onChange={(e) => setTypedText(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||||
|
placeholder={`Type "${confirmText}" here`}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{cancelButtonText}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!isConfirmEnabled}
|
||||||
|
variant={isDanger ? "destructive" : "default"}
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{confirmButtonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/app/_components/ContextualHelpIcon.tsx
Normal file
43
src/app/_components/ContextualHelpIcon.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { HelpModal } from './HelpModal';
|
||||||
|
import { HelpCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ContextualHelpIconProps {
|
||||||
|
section: string;
|
||||||
|
className?: string;
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextualHelpIcon({
|
||||||
|
section,
|
||||||
|
className = '',
|
||||||
|
size = 'sm',
|
||||||
|
tooltip = 'Help'
|
||||||
|
}: ContextualHelpIconProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const sizeClasses = size === 'sm'
|
||||||
|
? 'h-7 w-7 p-1.5'
|
||||||
|
: 'h-9 w-9 p-2';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer inline-flex items-center justify-center rounded-md transition-colors ${className}`}
|
||||||
|
title={tooltip}
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HelpModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
initialSection={section}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
|||||||
className="fixed inset-0 backdrop-blur-sm bg-black/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-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden border border-border">
|
<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-border">
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,16 +3,28 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { ScriptCard } from './ScriptCard';
|
import { ScriptCard } from './ScriptCard';
|
||||||
|
import { ScriptCardList } from './ScriptCardList';
|
||||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||||
import { CategorySidebar } from './CategorySidebar';
|
import { CategorySidebar } from './CategorySidebar';
|
||||||
import { FilterBar, type FilterState } from './FilterBar';
|
import { FilterBar, type FilterState } from './FilterBar';
|
||||||
|
import { ViewToggle } from './ViewToggle';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||||
|
|
||||||
export function DownloadedScriptsTab() {
|
interface DownloadedScriptsTabProps {
|
||||||
|
onInstallScript?: (
|
||||||
|
scriptPath: string,
|
||||||
|
scriptName: string,
|
||||||
|
mode?: "local" | "ssh",
|
||||||
|
server?: any,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
|
||||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
showUpdatable: null,
|
showUpdatable: null,
|
||||||
@@ -20,15 +32,106 @@ export function DownloadedScriptsTab() {
|
|||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
});
|
});
|
||||||
|
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||||
|
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getCtScripts.useQuery();
|
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||||
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||||
{ slug: selectedSlug ?? '' },
|
{ slug: selectedSlug ?? '' },
|
||||||
{ enabled: !!selectedSlug }
|
{ enabled: !!selectedSlug }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
// Load SAVE_FILTER setting
|
||||||
|
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||||
|
let saveFilterEnabled = false;
|
||||||
|
if (saveFilterResponse.ok) {
|
||||||
|
const saveFilterData = await saveFilterResponse.json();
|
||||||
|
saveFilterEnabled = saveFilterData.enabled ?? false;
|
||||||
|
setSaveFiltersEnabled(saveFilterEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved filters if SAVE_FILTER is enabled
|
||||||
|
if (saveFilterEnabled) {
|
||||||
|
const filtersResponse = await fetch('/api/settings/filters');
|
||||||
|
if (filtersResponse.ok) {
|
||||||
|
const filtersData = await filtersResponse.json();
|
||||||
|
if (filtersData.filters) {
|
||||||
|
setFilters(filtersData.filters as FilterState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load view mode
|
||||||
|
const viewModeResponse = await fetch('/api/settings/view-mode');
|
||||||
|
if (viewModeResponse.ok) {
|
||||||
|
const viewModeData = await viewModeResponse.json();
|
||||||
|
const viewMode = viewModeData.viewMode;
|
||||||
|
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
|
||||||
|
setViewMode(viewMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingFilters(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save filters when they change (if SAVE_FILTER is enabled)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!saveFiltersEnabled || isLoadingFilters) return;
|
||||||
|
|
||||||
|
const saveFilters = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings/filters', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filters }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving filters:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
const timeoutId = setTimeout(() => void saveFilters(), 500);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||||
|
|
||||||
|
// Save view mode when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoadingFilters) return;
|
||||||
|
|
||||||
|
const saveViewMode = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings/view-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ viewMode }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving view mode:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
const timeoutId = setTimeout(() => void saveViewMode(), 300);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [viewMode, isLoadingFilters]);
|
||||||
|
|
||||||
// Extract categories from metadata
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
@@ -282,7 +385,7 @@ export function DownloadedScriptsTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!downloadedScripts || downloadedScripts.length === 0) {
|
if (!downloadedScripts?.length) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
@@ -300,29 +403,12 @@ export function DownloadedScriptsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 gap-6">
|
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||||
{/* Category Sidebar */}
|
{/* Category Sidebar */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||||
<CategorySidebar
|
<CategorySidebar
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryCounts={categoryCounts}
|
categoryCounts={categoryCounts}
|
||||||
@@ -333,7 +419,7 @@ export function DownloadedScriptsTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 min-w-0" ref={gridRef}>
|
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
||||||
{/* Enhanced Filter Bar */}
|
{/* Enhanced Filter Bar */}
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -341,6 +427,14 @@ export function DownloadedScriptsTab() {
|
|||||||
totalScripts={downloadedScripts.length}
|
totalScripts={downloadedScripts.length}
|
||||||
filteredCount={filteredScripts.length}
|
filteredCount={filteredScripts.length}
|
||||||
updatableCount={filterCounts.updatableCount}
|
updatableCount={filterCounts.updatableCount}
|
||||||
|
saveFiltersEnabled={saveFiltersEnabled}
|
||||||
|
isLoadingFilters={isLoadingFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<ViewToggle
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scripts Grid */}
|
{/* Scripts Grid */}
|
||||||
@@ -377,34 +471,54 @@ export function DownloadedScriptsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
viewMode === 'card' ? (
|
||||||
{filteredScripts.map((script, index) => {
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
// Add validation to ensure script has required properties
|
{filteredScripts.map((script, index) => {
|
||||||
if (!script || typeof script !== 'object') {
|
// Add validation to ensure script has required properties
|
||||||
return null;
|
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}`;
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
return (
|
|
||||||
<ScriptCard
|
return (
|
||||||
key={uniqueKey}
|
<ScriptCard
|
||||||
script={script}
|
key={uniqueKey}
|
||||||
onClick={handleCardClick}
|
script={script}
|
||||||
/>
|
onClick={handleCardClick}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredScripts.map((script, index) => {
|
||||||
|
// Add validation to ensure script has required properties
|
||||||
|
if (!script || typeof script !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScriptCardList
|
||||||
|
key={uniqueKey}
|
||||||
|
script={script}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScriptDetailModal
|
<ScriptDetailModal
|
||||||
script={scriptData?.success ? scriptData.script : null}
|
script={scriptData?.success ? scriptData.script : null}
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
onInstallScript={() => {
|
onInstallScript={onInstallScript}
|
||||||
// Downloaded scripts don't need installation
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
87
src/app/_components/ErrorModal.tsx
Normal file
87
src/app/_components/ErrorModal.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ErrorModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
details?: string;
|
||||||
|
type?: 'error' | 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
type = 'error'
|
||||||
|
}: ErrorModalProps) {
|
||||||
|
// Auto-close after 10 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 10000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-lg w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{type === 'success' ? (
|
||||||
|
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-foreground mb-4">{message}</p>
|
||||||
|
{details && (
|
||||||
|
<div className={`rounded-lg p-3 ${
|
||||||
|
type === 'success'
|
||||||
|
? 'bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800'
|
||||||
|
: 'bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800'
|
||||||
|
}`}>
|
||||||
|
<p className={`text-xs font-medium mb-1 ${
|
||||||
|
type === 'success'
|
||||||
|
? 'text-green-800 dark:text-green-200'
|
||||||
|
: 'text-red-800 dark:text-red-200'
|
||||||
|
}`}>
|
||||||
|
{type === 'success' ? 'Details:' : 'Error Details:'}
|
||||||
|
</p>
|
||||||
|
<pre className={`text-xs whitespace-pre-wrap break-words ${
|
||||||
|
type === 'success'
|
||||||
|
? 'text-green-700 dark:text-green-300'
|
||||||
|
: 'text-red-700 dark:text-red-300'
|
||||||
|
}`}>
|
||||||
|
{details}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 p-6 border-t border-border">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { Server } from '../../types/server';
|
import type { Server } from '../../types/server';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
||||||
|
import { SettingsModal } from './SettingsModal';
|
||||||
|
|
||||||
|
|
||||||
interface ExecutionModeModalProps {
|
interface ExecutionModeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -15,8 +18,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local');
|
|
||||||
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
||||||
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -24,6 +27,20 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Auto-select server when exactly one server is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !loading && servers.length === 1) {
|
||||||
|
setSelectedServer(servers[0] ?? null);
|
||||||
|
}
|
||||||
|
}, [isOpen, loading, servers]);
|
||||||
|
|
||||||
|
// Refresh servers when settings modal closes
|
||||||
|
const handleSettingsModalClose = () => {
|
||||||
|
setSettingsModalOpen(false);
|
||||||
|
// Refetch servers to reflect any changes made in settings
|
||||||
|
void fetchServers();
|
||||||
|
};
|
||||||
|
|
||||||
const fetchServers = async () => {
|
const fetchServers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -33,7 +50,11 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
throw new Error('Failed to fetch servers');
|
throw new Error('Failed to fetch servers');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setServers(data as Server[]);
|
// Sort servers by name alphabetically
|
||||||
|
const sortedServers = (data as Server[]).sort((a, b) =>
|
||||||
|
(a.name ?? '').localeCompare(b.name ?? '')
|
||||||
|
);
|
||||||
|
setServers(sortedServers);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,167 +63,175 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExecute = () => {
|
const handleExecute = () => {
|
||||||
if (selectedMode === 'ssh' && !selectedServer) {
|
if (!selectedServer) {
|
||||||
setError('Please select a server for SSH execution');
|
setError('Please select a server for SSH execution');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onExecute(selectedMode, selectedServer ?? undefined);
|
onExecute('ssh', selectedServer);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModeChange = (mode: 'local' | 'ssh') => {
|
|
||||||
setSelectedMode(mode);
|
const handleServerSelect = (server: Server | null) => {
|
||||||
if (mode === 'local') {
|
setSelectedServer(server);
|
||||||
setSelectedServer(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50">
|
<>
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full mx-4 border border-border">
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
{/* Header */}
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
{/* Header */}
|
||||||
<h2 className="text-xl font-bold text-foreground">Execution Mode</h2>
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<Button
|
<h2 className="text-xl font-bold text-foreground">Select Server</h2>
|
||||||
onClick={onClose}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
|
||||||
Where would you like to execute "{scriptName}"?
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Execution Mode Selection */}
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
|
|
||||||
|
|
||||||
{/* SSH Execution */}
|
|
||||||
<div
|
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
|
||||||
selectedMode === 'ssh'
|
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: 'border-border hover:border-primary/50'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleModeChange('ssh')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id="ssh"
|
|
||||||
name="executionMode"
|
|
||||||
value="ssh"
|
|
||||||
checked={selectedMode === 'ssh'}
|
|
||||||
onChange={() => handleModeChange('ssh')}
|
|
||||||
className="h-4 w-4 text-primary focus:ring-primary border-border"
|
|
||||||
/>
|
|
||||||
<label htmlFor="ssh" className="ml-3 flex-1 cursor-pointer">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h4 className="text-sm font-medium text-foreground">SSH Execution</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">Run the script on a remote server</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Server Selection (only for SSH mode) */}
|
|
||||||
{selectedMode === 'ssh' && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
|
|
||||||
Select Server
|
|
||||||
</label>
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
|
|
||||||
</div>
|
|
||||||
) : servers.length === 0 ? (
|
|
||||||
<div className="text-center py-4 text-muted-foreground">
|
|
||||||
<p className="text-sm">No servers configured</p>
|
|
||||||
<p className="text-xs mt-1">Add servers in Settings to use SSH execution</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
id="server"
|
|
||||||
value={selectedServer?.id ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const serverId = parseInt(e.target.value);
|
|
||||||
const server = servers.find(s => s.id === serverId);
|
|
||||||
setSelectedServer(server ?? null);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary bg-background text-foreground"
|
|
||||||
>
|
|
||||||
<option value="">Select a server...</option>
|
|
||||||
{servers.map((server) => (
|
|
||||||
<option key={server.id} value={server.id}>
|
|
||||||
{server.name} ({server.ip}) - {server.user}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-end space-x-3">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="default"
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Cancel
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</Button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
<Button
|
</svg>
|
||||||
onClick={handleExecute}
|
|
||||||
disabled={selectedMode === 'ssh' && !selectedServer}
|
|
||||||
variant="default"
|
|
||||||
size="default"
|
|
||||||
className={selectedMode === 'ssh' && !selectedServer ? 'bg-gray-400 cursor-not-allowed' : ''}
|
|
||||||
>
|
|
||||||
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
|
||||||
|
</div>
|
||||||
|
) : servers.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<p className="text-sm">No servers configured</p>
|
||||||
|
<p className="text-xs mt-1">Add servers in Settings to execute scripts</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setSettingsModalOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
>
|
||||||
|
Open Server Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : servers.length === 1 ? (
|
||||||
|
/* Single Server Confirmation View */
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
Install Script Confirmation
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Do you want to install "{scriptName}" on the following server?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{selectedServer?.name ?? 'Unnamed Server'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectedServer?.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExecute}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Multiple Servers Selection View */
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
Select server to execute "{scriptName}"
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Selection */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Select Server
|
||||||
|
</label>
|
||||||
|
<ColorCodedDropdown
|
||||||
|
servers={servers}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
onServerSelect={handleServerSelect}
|
||||||
|
placeholder="Select a server..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={!selectedServer}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className={!selectedServer ? 'bg-gray-400 cursor-not-allowed' : ''}
|
||||||
|
>
|
||||||
|
Run on Server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Server Settings Modal */}
|
||||||
|
<SettingsModal
|
||||||
|
isOpen={settingsModalOpen}
|
||||||
|
onClose={handleSettingsModalClose}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Package, Monitor, Wrench, Server, FileText, Calendar } from "lucide-react";
|
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||||
|
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -18,6 +19,8 @@ interface FilterBarProps {
|
|||||||
totalScripts: number;
|
totalScripts: number;
|
||||||
filteredCount: number;
|
filteredCount: number;
|
||||||
updatableCount?: number;
|
updatableCount?: number;
|
||||||
|
saveFiltersEnabled?: boolean;
|
||||||
|
isLoadingFilters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCRIPT_TYPES = [
|
const SCRIPT_TYPES = [
|
||||||
@@ -33,8 +36,11 @@ export function FilterBar({
|
|||||||
totalScripts,
|
totalScripts,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
updatableCount = 0,
|
updatableCount = 0,
|
||||||
|
saveFiltersEnabled = false,
|
||||||
|
isLoadingFilters = false,
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||||
|
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const updateFilters = (updates: Partial<FilterState>) => {
|
const updateFilters = (updates: Partial<FilterState>) => {
|
||||||
onFiltersChange({ ...filters, ...updates });
|
onFiltersChange({ ...filters, ...updates });
|
||||||
@@ -76,10 +82,29 @@ export function FilterBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-border bg-card p-6 shadow-sm">
|
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoadingFilters && (
|
||||||
|
<div className="mb-4 flex items-center justify-center py-2">
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||||
|
<span>Loading saved filters...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Filter Header */}
|
||||||
|
{!isLoadingFilters && (
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
|
||||||
|
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="relative max-w-md">
|
<div className="relative max-w-md w-full">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-muted-foreground"
|
className="h-5 w-5 text-muted-foreground"
|
||||||
@@ -128,7 +153,7 @@ export function FilterBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Buttons */}
|
{/* Filter Buttons */}
|
||||||
<div className="mb-4 flex flex-wrap gap-3">
|
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
||||||
{/* Updateable Filter */}
|
{/* Updateable Filter */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -142,29 +167,31 @@ export function FilterBar({
|
|||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className={`${
|
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||||
filters.showUpdatable === null
|
filters.showUpdatable === null
|
||||||
? "bg-muted text-muted-foreground hover:bg-accent"
|
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
: filters.showUpdatable === true
|
: filters.showUpdatable === true
|
||||||
? "border border-green-500/20 bg-green-500/10 text-green-400"
|
? "border border-green-500/20 bg-green-500/10 text-green-400"
|
||||||
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getUpdatableButtonText()}
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
<span>{getUpdatableButtonText()}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Type Dropdown */}
|
{/* Type Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative w-full sm:w-auto">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className={`flex items-center space-x-2 ${
|
className={`w-full flex items-center justify-center space-x-2 ${
|
||||||
filters.selectedTypes.length === 0
|
filters.selectedTypes.length === 0
|
||||||
? "bg-muted text-muted-foreground hover:bg-accent"
|
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
: "border border-primary/20 bg-primary/10 text-primary"
|
: "border border-primary/20 bg-primary/10 text-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
<span>{getTypeButtonText()}</span>
|
<span>{getTypeButtonText()}</span>
|
||||||
<svg
|
<svg
|
||||||
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
||||||
@@ -237,97 +264,146 @@ export function FilterBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Options */}
|
{/* Sort By Dropdown */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="relative w-full sm:w-auto">
|
||||||
{/* Sort By Dropdown */}
|
|
||||||
<div className="relative inline-flex items-center">
|
|
||||||
<select
|
|
||||||
value={filters.sortBy}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateFilters({ sortBy: e.target.value as "name" | "created" })
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-input bg-background pl-9 pr-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-primary focus:outline-none appearance-none"
|
|
||||||
>
|
|
||||||
<option value="name">By Name</option>
|
|
||||||
<option value="created">By Created Date</option>
|
|
||||||
</select>
|
|
||||||
<div className="absolute left-2 pointer-events-none">
|
|
||||||
{filters.sortBy === "name" ? (
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort Order Button */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||||
updateFilters({
|
|
||||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="flex items-center space-x-1 bg-muted text-muted-foreground hover:bg-accent"
|
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.sortOrder === "asc" ? (
|
{filters.sortBy === "name" ? (
|
||||||
<>
|
<FileText className="h-4 w-4" />
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Calendar className="h-4 w-4" />
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<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>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Filter Summary and Clear All */}
|
{/* Filter Summary and Clear All */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-4">
|
||||||
{filteredCount === totalScripts ? (
|
<div className="text-sm text-muted-foreground">
|
||||||
<span>Showing all {totalScripts} scripts</span>
|
{filteredCount === totalScripts ? (
|
||||||
) : (
|
<span>Showing all {totalScripts} scripts</span>
|
||||||
<span>
|
) : (
|
||||||
{filteredCount} of {totalScripts} scripts{" "}
|
<span>
|
||||||
{hasActiveFilters && (
|
{filteredCount} of {totalScripts} scripts{" "}
|
||||||
<span className="font-medium text-blue-600">
|
{hasActiveFilters && (
|
||||||
(filtered)
|
<span className="font-medium text-blue-600">
|
||||||
</span>
|
(filtered)
|
||||||
)}
|
</span>
|
||||||
</span>
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Persistence Status */}
|
||||||
|
{!isLoadingFilters && saveFiltersEnabled && (
|
||||||
|
<div className="flex items-center space-x-1 text-xs text-green-600">
|
||||||
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>Filters are being saved automatically</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -336,7 +412,7 @@ export function FilterBar({
|
|||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800"
|
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800 w-full sm:w-auto justify-center sm:justify-start"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -356,11 +432,14 @@ export function FilterBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Click outside to close dropdown */}
|
{/* Click outside to close dropdowns */}
|
||||||
{isTypeDropdownOpen && (
|
{(isTypeDropdownOpen || isSortDropdownOpen) && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-0"
|
className="fixed inset-0 z-0"
|
||||||
onClick={() => setIsTypeDropdownOpen(false)}
|
onClick={() => {
|
||||||
|
setIsTypeDropdownOpen(false);
|
||||||
|
setIsSortDropdownOpen(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
64
src/app/_components/Footer.tsx
Normal file
64
src/app/_components/Footer.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { ExternalLink, FileText } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
onOpenReleaseNotes: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer({ onOpenReleaseNotes }: FooterProps) {
|
||||||
|
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-3 backdrop-blur-sm">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>© 2024 PVE Scripts Local</span>
|
||||||
|
{versionData?.success && versionData.version && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenReleaseNotes}
|
||||||
|
className="h-auto p-1 text-xs hover:text-foreground"
|
||||||
|
>
|
||||||
|
v{versionData.version}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenReleaseNotes}
|
||||||
|
className="h-auto p-2 text-xs hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FileText className="h-3 w-3 mr-1" />
|
||||||
|
Release Notes
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="h-auto p-2 text-xs hover:text-foreground"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://github.com/community-scripts/ProxmoxVE-Local"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
597
src/app/_components/GeneralSettingsModal.tsx
Normal file
597
src/app/_components/GeneralSettingsModal.tsx
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Toggle } from './ui/toggle';
|
||||||
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
|
|
||||||
|
interface GeneralSettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general');
|
||||||
|
const [githubToken, setGithubToken] = useState('');
|
||||||
|
const [saveFilter, setSaveFilter] = useState(false);
|
||||||
|
const [savedFilters, setSavedFilters] = useState<any>(null);
|
||||||
|
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Auth state
|
||||||
|
const [authUsername, setAuthUsername] = useState('');
|
||||||
|
const [authPassword, setAuthPassword] = useState('');
|
||||||
|
const [authConfirmPassword, setAuthConfirmPassword] = useState('');
|
||||||
|
const [authEnabled, setAuthEnabled] = useState(false);
|
||||||
|
const [authHasCredentials, setAuthHasCredentials] = useState(false);
|
||||||
|
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
|
||||||
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
|
|
||||||
|
// Load existing settings when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void loadGithubToken();
|
||||||
|
void loadSaveFilter();
|
||||||
|
void loadSavedFilters();
|
||||||
|
void loadAuthCredentials();
|
||||||
|
void loadColorCodingSetting();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const loadGithubToken = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/github-token');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setGithubToken((data.token as string) ?? '');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading GitHub token:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSaveFilter = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/save-filter');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSaveFilter((data.enabled as boolean) ?? false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading save filter setting:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSaveFilter = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/save-filter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSaveFilter(enabled);
|
||||||
|
setMessage({ type: 'success', text: 'Save filter setting updated!' });
|
||||||
|
|
||||||
|
// If disabling save filters, clear saved filters
|
||||||
|
if (!enabled) {
|
||||||
|
await clearSavedFilters();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save setting' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save setting' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSavedFilters = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/filters');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSavedFilters(data.filters);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading saved filters:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSavedFilters = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/filters', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSavedFilters(null);
|
||||||
|
setMessage({ type: 'success', text: 'Saved filters cleared!' });
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setMessage({ type: 'error', text: errorData.error ?? 'Failed to clear filters' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to clear filters' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveGithubToken = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/github-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token: githubToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'GitHub token saved successfully!' });
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save token' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save token' });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadColorCodingSetting = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/color-coding');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setColorCodingEnabled(Boolean(data.enabled));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading color coding setting:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveColorCodingSetting = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/color-coding', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setColorCodingEnabled(enabled);
|
||||||
|
setMessage({ type: 'success', text: 'Color coding setting saved successfully' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving color coding setting:', error);
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAuthCredentials = async () => {
|
||||||
|
setAuthLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/auth-credentials');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean };
|
||||||
|
setAuthUsername(data.username ?? '');
|
||||||
|
setAuthEnabled(data.enabled ?? false);
|
||||||
|
setAuthHasCredentials(data.hasCredentials ?? false);
|
||||||
|
setAuthSetupCompleted(data.setupCompleted ?? false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading auth credentials:', error);
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAuthCredentials = async () => {
|
||||||
|
if (authPassword !== authConfirmPassword) {
|
||||||
|
setMessage({ type: 'error', text: 'Passwords do not match' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthLoading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/auth-credentials', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: authUsername,
|
||||||
|
password: authPassword,
|
||||||
|
enabled: authEnabled
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Authentication credentials updated successfully!' });
|
||||||
|
setAuthPassword('');
|
||||||
|
setAuthConfirmPassword('');
|
||||||
|
void loadAuthCredentials();
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save credentials' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save credentials' });
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAuthEnabled = async (enabled: boolean) => {
|
||||||
|
setAuthLoading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/auth-credentials', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setAuthEnabled(enabled);
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully!`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setMessage({ type: 'error', text: errorData.error ?? 'Failed to update auth status' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to update auth status' });
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||||
|
<ContextualHelpIcon section="general-settings" tooltip="Help with General Settings" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-8 px-4 sm:px-6">
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('general')}
|
||||||
|
variant="ghost"
|
||||||
|
size="null"
|
||||||
|
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||||
|
activeTab === 'general'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
General
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('github')}
|
||||||
|
variant="ghost"
|
||||||
|
size="null"
|
||||||
|
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||||
|
activeTab === 'github'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('auth')}
|
||||||
|
variant="ghost"
|
||||||
|
size="null"
|
||||||
|
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||||
|
activeTab === 'auth'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Authentication
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||||
|
{activeTab === 'general' && (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
|
||||||
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||||
|
Configure general application preferences and behavior.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Save Filters</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Save your configured script filters.</p>
|
||||||
|
<Toggle
|
||||||
|
checked={saveFilter}
|
||||||
|
onCheckedChange={saveSaveFilter}
|
||||||
|
label="Enable filter saving"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{saveFilter && (
|
||||||
|
<div className="mt-4 p-3 bg-muted rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Saved Filters</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{savedFilters ? 'Filters are currently saved' : 'No filters saved yet'}
|
||||||
|
</p>
|
||||||
|
{savedFilters && (
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
<div>Search: {savedFilters.searchQuery ?? 'None'}</div>
|
||||||
|
<div>Types: {savedFilters.selectedTypes?.length ?? 0} selected</div>
|
||||||
|
<div>Sort: {savedFilters.sortBy} ({savedFilters.sortOrder})</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{savedFilters && (
|
||||||
|
<Button
|
||||||
|
onClick={clearSavedFilters}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Enable color coding for servers to visually distinguish them throughout the application.</p>
|
||||||
|
<Toggle
|
||||||
|
checked={colorCodingEnabled}
|
||||||
|
onCheckedChange={saveColorCodingSetting}
|
||||||
|
label="Enable server color coding"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'github' && (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">GitHub Integration</h3>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||||
|
Configure GitHub integration for script management and updates.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">GitHub Personal Access Token</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Save a GitHub Personal Access Token to circumvent GitHub API rate limits.</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="github-token" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="github-token"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your GitHub Personal Access Token"
|
||||||
|
value={githubToken}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGithubToken(e.target.value)}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`p-3 rounded-md text-sm ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-green-50 text-green-800 border border-green-200'
|
||||||
|
: 'bg-red-50 text-red-800 border border-red-200'
|
||||||
|
}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={saveGithubToken}
|
||||||
|
disabled={isSaving || isLoading || !githubToken.trim()}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Token'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={loadGithubToken}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Loading...' : 'Refresh'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'auth' && (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||||
|
Configure authentication to secure access to your application.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Authentication Status</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{authSetupCompleted
|
||||||
|
? (authHasCredentials
|
||||||
|
? `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. Current username: ${authUsername}`
|
||||||
|
: `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. No credentials configured.`)
|
||||||
|
: 'Authentication setup has not been completed yet.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Enable Authentication</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{authEnabled
|
||||||
|
? 'Authentication is required on every page load'
|
||||||
|
: 'Authentication is optional'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={authEnabled}
|
||||||
|
onCheckedChange={toggleAuthEnabled}
|
||||||
|
disabled={authLoading || !authSetupCompleted}
|
||||||
|
label="Enable authentication"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Change your username and password for authentication.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="auth-username" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="auth-username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter username"
|
||||||
|
value={authUsername}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthUsername(e.target.value)}
|
||||||
|
disabled={authLoading}
|
||||||
|
className="w-full"
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="auth-password" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="auth-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter new password"
|
||||||
|
value={authPassword}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthPassword(e.target.value)}
|
||||||
|
disabled={authLoading}
|
||||||
|
className="w-full"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="auth-confirm-password" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="auth-confirm-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
value={authConfirmPassword}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthConfirmPassword(e.target.value)}
|
||||||
|
disabled={authLoading}
|
||||||
|
className="w-full"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`p-3 rounded-md text-sm ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-green-50 text-green-800 border border-green-200'
|
||||||
|
: 'bg-red-50 text-red-800 border border-red-200'
|
||||||
|
}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={saveAuthCredentials}
|
||||||
|
disabled={authLoading || !authUsername.trim() || !authPassword.trim() || !authConfirmPassword.trim()}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{authLoading ? 'Saving...' : 'Update Credentials'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={loadAuthCredentials}
|
||||||
|
disabled={authLoading}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{authLoading ? 'Loading...' : 'Refresh'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/app/_components/HelpButton.tsx
Normal file
40
src/app/_components/HelpButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { HelpModal } from './HelpModal';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { HelpCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface HelpButtonProps {
|
||||||
|
initialSection?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpButton({ initialSection }: HelpButtonProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
|
Need help?
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="inline-flex items-center"
|
||||||
|
title="Open Help"
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-5 h-5 mr-2" />
|
||||||
|
Help
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HelpModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
initialSection={initialSection}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
562
src/app/_components/HelpModal.tsx
Normal file
562
src/app/_components/HelpModal.tsx
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { HelpCircle, Server, Settings, RefreshCw, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
interface HelpModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialSection?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'update-system';
|
||||||
|
|
||||||
|
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
||||||
|
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
|
||||||
|
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings },
|
||||||
|
{ id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
|
||||||
|
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
|
||||||
|
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
|
||||||
|
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
|
||||||
|
{ id: 'update-system' as HelpSection, label: 'Update System', icon: Download },
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (activeSection) {
|
||||||
|
case 'server-settings':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-4">Server Settings</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Manage your Proxmox VE servers and configure connection settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Adding PVE Servers</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Server Name:</strong> A friendly name to identify your server</li>
|
||||||
|
<li>• <strong>IP Address:</strong> The IP address or hostname of your PVE server</li>
|
||||||
|
<li>• <strong>Username:</strong> PVE user account (usually root or a dedicated user)</li>
|
||||||
|
<li>• <strong>SSH Port:</strong> Default is 22, change if your server uses a different port</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Authentication Types</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Password:</strong> Use username and password authentication</li>
|
||||||
|
<li>• <strong>SSH Key:</strong> Use SSH key pair for secure authentication</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-md">
|
||||||
|
<h5 className="font-medium text-blue-900 dark:text-blue-100 mb-2">SSH Key Features:</h5>
|
||||||
|
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||||
|
<li>• <strong>Generate Key Pair:</strong> Create new SSH keys automatically</li>
|
||||||
|
<li>• <strong>View Public Key:</strong> Copy public key for server setup</li>
|
||||||
|
<li>• <strong>Persistent Storage:</strong> Keys are stored securely on disk</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Assign colors to servers for visual distinction throughout the application.
|
||||||
|
This helps identify which server you're working with when managing scripts.
|
||||||
|
This needs to be enabled in the General Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'general-settings':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-4">General Settings</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Configure application preferences and behavior.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Save Filters</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
When enabled, your script filter preferences (search terms, categories, sorting)
|
||||||
|
will be automatically saved and restored when you return to the application.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Search queries are preserved</li>
|
||||||
|
<li>• Selected script types are remembered</li>
|
||||||
|
<li>• Sort preferences are maintained</li>
|
||||||
|
<li>• Category selections are saved</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Enable visual color coding for servers throughout the application.
|
||||||
|
This makes it easier to identify which server you're working with.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">GitHub Integration</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Add a GitHub Personal Access Token to increase API rate limits and improve performance.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Bypasses GitHub's rate limiting for unauthenticated requests</li>
|
||||||
|
<li>• Improves script loading and syncing performance</li>
|
||||||
|
<li>• Token is stored securely and only used for API calls</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Authentication</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Secure your application with username and password authentication.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Set up username and password for app access</li>
|
||||||
|
<li>• Enable/disable authentication as needed</li>
|
||||||
|
<li>• Credentials are stored securely</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'sync-button':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-4">Sync Button</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Synchronize script metadata from the ProxmoxVE GitHub repository.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">What Does Syncing Do?</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Updates Script Metadata:</strong> Downloads the latest script information (JSON files)</li>
|
||||||
|
<li>• <strong>Refreshes Available Scripts:</strong> Updates the list of scripts you can download</li>
|
||||||
|
<li>• <strong>Updates Categories:</strong> Refreshes script categories and organization</li>
|
||||||
|
<li>• <strong>Checks for Updates:</strong> Identifies which downloaded scripts have newer versions</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Important Notes</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Metadata Only:</strong> Syncing only updates script information, not the actual script files</li>
|
||||||
|
<li>• <strong>No Downloads:</strong> Script files are downloaded separately when you choose to install them</li>
|
||||||
|
<li>• <strong>Last Sync Time:</strong> Shows when the last successful sync occurred</li>
|
||||||
|
<li>• <strong>Rate Limits:</strong> GitHub API limits may apply without a personal access token</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">When to Sync</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• When you want to see the latest available scripts</li>
|
||||||
|
<li>• To check for updates to your downloaded scripts</li>
|
||||||
|
<li>• If you notice scripts are missing or outdated</li>
|
||||||
|
<li>• After the ProxmoxVE repository has been updated</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'available-scripts':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-4">Available Scripts</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Browse and discover scripts from the ProxmoxVE repository.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Browsing Scripts</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Category Sidebar:</strong> Filter scripts by category (Storage, Network, Security, etc.)</li>
|
||||||
|
<li>• <strong>Search:</strong> Find scripts by name or description</li>
|
||||||
|
<li>• <strong>View Modes:</strong> Switch between card and list view</li>
|
||||||
|
<li>• <strong>Sorting:</strong> Sort by name or creation date</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Filtering Options</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Script Types:</strong> Filter by CT (Container) or other script types</li>
|
||||||
|
<li>• <strong>Update Status:</strong> Show only scripts with available updates</li>
|
||||||
|
<li>• <strong>Search Query:</strong> Search within script names and descriptions</li>
|
||||||
|
<li>• <strong>Categories:</strong> Filter by specific script categories</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Script Actions</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>View Details:</strong> Click on a script to see full information and documentation</li>
|
||||||
|
<li>• <strong>Download:</strong> Download script files to your local system</li>
|
||||||
|
<li>• <strong>Install:</strong> Run scripts directly on your PVE servers</li>
|
||||||
|
<li>• <strong>Preview:</strong> View script content before downloading</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'downloaded-scripts':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-4">Downloaded Scripts</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Manage scripts that have been downloaded to your local system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">What Are Downloaded Scripts?</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
These are scripts that you've downloaded from the repository and are stored locally on your system.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Script files are stored in your local scripts directory</li>
|
||||||
|
<li>• You can run these scripts on your PVE servers</li>
|
||||||
|
<li>• Scripts can be updated when newer versions are available</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Update Detection</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
The system automatically checks if newer versions of your downloaded scripts are available.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Scripts with updates available are marked with an update indicator</li>
|
||||||
|
<li>• You can filter to show only scripts with available updates</li>
|
||||||
|
<li>• Update detection happens when you sync with the repository</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Managing Downloaded Scripts</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Update Scripts:</strong> Download the latest version of a script</li>
|
||||||
|
<li>• <strong>View Details:</strong> See script information and documentation</li>
|
||||||
|
<li>• <strong>Install/Run:</strong> Execute scripts on your PVE servers</li>
|
||||||
|
<li>• <strong>Filter & Search:</strong> Use the same filtering options as Available Scripts</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'installed-scripts':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-4">Installed Scripts</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Track and manage scripts that are installed on your PVE servers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-muted/50 border-primary/20">
|
||||||
|
<h4 className="font-medium text-foreground mb-2 flex items-center gap-2">
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
Auto-Detection (Primary Feature)
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
The system can automatically detect LXC containers that have community-script tags on your PVE servers.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Automatic Discovery:</strong> Scans your PVE servers for containers with community-script tags</li>
|
||||||
|
<li>• <strong>Container Detection:</strong> Identifies LXC containers running Proxmox helper scripts</li>
|
||||||
|
<li>• <strong>Server Association:</strong> Links detected scripts to the specific PVE server</li>
|
||||||
|
<li>• <strong>Bulk Import:</strong> Automatically creates records for all detected scripts</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3 p-3 bg-primary/10 rounded-lg border border-primary/20">
|
||||||
|
<p className="text-sm font-medium text-primary">How Auto-Detection Works:</p>
|
||||||
|
<ol className="text-sm text-muted-foreground mt-1 space-y-1">
|
||||||
|
<li>1. Connects to your configured PVE servers</li>
|
||||||
|
<li>2. Scans LXC container configurations</li>
|
||||||
|
<li>3. Looks for containers with community-script tags</li>
|
||||||
|
<li>4. Creates installed script records automatically</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Manual Script Management</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Add Scripts Manually:</strong> Create records for scripts not auto-detected</li>
|
||||||
|
<li>• <strong>Edit Script Details:</strong> Update script names and container IDs</li>
|
||||||
|
<li>• <strong>Delete Scripts:</strong> Remove scripts from tracking</li>
|
||||||
|
<li>• <strong>Bulk Operations:</strong> Clean up old or invalid script records</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Script Tracking Features</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Installation Status:</strong> Track success, failure, or in-progress installations</li>
|
||||||
|
<li>• <strong>Server Association:</strong> Know which server each script is installed on</li>
|
||||||
|
<li>• <strong>Container ID:</strong> Link scripts to specific LXC containers</li>
|
||||||
|
<li>• <strong>Web UI Access:</strong> Track and access Web UI IP addresses and ports</li>
|
||||||
|
<li>• <strong>Execution Logs:</strong> View output and logs from script installations</li>
|
||||||
|
<li>• <strong>Filtering:</strong> Filter by server, status, or search terms</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Managing Installed Scripts</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>View All Scripts:</strong> See all tracked scripts across all servers</li>
|
||||||
|
<li>• <strong>Filter by Server:</strong> Show scripts for a specific PVE server</li>
|
||||||
|
<li>• <strong>Filter by Status:</strong> Show successful, failed, or in-progress installations</li>
|
||||||
|
<li>• <strong>Sort Options:</strong> Sort by name, container ID, server, status, or date</li>
|
||||||
|
<li>• <strong>Update Scripts:</strong> Re-run or update existing script installations</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-blue-900/20 border-blue-700/50">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Web UI Access </h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Automatically detect and access Web UI interfaces for your installed scripts.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Auto-Detection:</strong> Automatically detects Web UI URLs from script installation output</li>
|
||||||
|
<li>• <strong>IP & Port Tracking:</strong> Stores and displays Web UI IP addresses and ports</li>
|
||||||
|
<li>• <strong>One-Click Access:</strong> Click IP:port to open Web UI in new tab</li>
|
||||||
|
<li>• <strong>Manual Detection:</strong> Re-detect IP using <code>hostname -I</code> inside container</li>
|
||||||
|
<li>• <strong>Port Detection:</strong> Uses script metadata to get correct port (e.g., actualbudget:5006)</li>
|
||||||
|
<li>• <strong>Editable Fields:</strong> Manually edit IP and port values as needed</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3 p-3 bg-blue-900/30 rounded-lg border border-blue-700/30">
|
||||||
|
<p className="text-sm font-medium text-blue-300">💡 How it works:</p>
|
||||||
|
<ul className="text-sm text-muted-foreground mt-1 space-y-1">
|
||||||
|
<li>• Scripts automatically detect URLs like <code>http://10.10.10.1:3000</code> during installation</li>
|
||||||
|
<li>• Re-detect button runs <code>hostname -I</code> inside the container via SSH</li>
|
||||||
|
<li>• Port defaults to 80, but uses script metadata when available</li>
|
||||||
|
<li>• Web UI buttons are disabled when container is stopped</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Actions Dropdown </h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Clean interface with all actions organized in a dropdown menu.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Edit Button:</strong> Always visible for quick script editing</li>
|
||||||
|
<li>• <strong>Actions Dropdown:</strong> Contains Update, Shell, Open UI, Start/Stop, Destroy, Delete</li>
|
||||||
|
<li>• <strong>Smart Visibility:</strong> Dropdown only appears when actions are available</li>
|
||||||
|
<li>• <strong>Color Coding:</strong> Start (green), Stop (red), Update (cyan), Shell (gray), Open UI (blue)</li>
|
||||||
|
<li>• <strong>Auto-Close:</strong> Dropdown closes after clicking any action</li>
|
||||||
|
<li>• <strong>Disabled States:</strong> Actions are disabled when container is stopped</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Container Control</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Directly control LXC containers from the installed scripts page via SSH.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Start/Stop Button:</strong> Control container state with <code>pct start/stop <ID></code></li>
|
||||||
|
<li>• <strong>Container Status:</strong> Real-time status indicator (running/stopped/unknown)</li>
|
||||||
|
<li>• <strong>Destroy Button:</strong> Permanently remove LXC container with <code>pct destroy <ID></code></li>
|
||||||
|
<li>• <strong>Confirmation Modals:</strong> Simple OK/Cancel for start/stop, type container ID to confirm destroy</li>
|
||||||
|
<li>• <strong>SSH Execution:</strong> All commands executed remotely via configured SSH connections</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3 p-3 bg-muted/30 dark:bg-muted/20 rounded-lg border border-border">
|
||||||
|
<p className="text-sm font-medium text-foreground">⚠️ Safety Features:</p>
|
||||||
|
<ul className="text-sm text-muted-foreground mt-1 space-y-1">
|
||||||
|
<li>• Start/Stop actions require simple confirmation</li>
|
||||||
|
<li>• Destroy action requires typing the container ID to confirm</li>
|
||||||
|
<li>• All actions show loading states and error handling</li>
|
||||||
|
<li>• Only works with SSH scripts that have valid container IDs</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'update-system':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-4">Update System</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Keep your PVE Scripts Management application up to date with the latest features and improvements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">What Does Updating Do?</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Downloads Latest Version:</strong> Fetches the newest release from the GitHub repository</li>
|
||||||
|
<li>• <strong>Updates Application Files:</strong> Replaces current files with the latest version</li>
|
||||||
|
<li>• <strong>Installs Dependencies:</strong> Updates Node.js packages and dependencies</li>
|
||||||
|
<li>• <strong>Rebuilds Application:</strong> Compiles the application with latest changes</li>
|
||||||
|
<li>• <strong>Restarts Server:</strong> Automatically restarts the application server</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">How to Update</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-foreground mb-2">Automatic Update (Recommended)</h5>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Click the "Update Now" button when an update is available</li>
|
||||||
|
<li>• The system will handle everything automatically</li>
|
||||||
|
<li>• You'll see a progress overlay with update logs</li>
|
||||||
|
<li>• The page will reload automatically when complete</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-foreground mb-2">Manual Update (Advanced)</h5>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">If automatic update fails, you can update manually:</p>
|
||||||
|
<div className="bg-muted p-3 rounded-lg font-mono text-sm">
|
||||||
|
<div className="text-muted-foreground"># Navigate to the application directory</div>
|
||||||
|
<div>cd $PVESCRIPTLOCAL_DIR</div>
|
||||||
|
<div className="text-muted-foreground"># Pull latest changes</div>
|
||||||
|
<div>git pull</div>
|
||||||
|
<div className="text-muted-foreground"># Install dependencies</div>
|
||||||
|
<div>npm install</div>
|
||||||
|
<div className="text-muted-foreground"># Build the application</div>
|
||||||
|
<div>npm run build</div>
|
||||||
|
<div className="text-muted-foreground"># Start the application</div>
|
||||||
|
<div>npm start</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Update Process</h4>
|
||||||
|
<ol className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li><strong>1. Check for Updates:</strong> System automatically checks GitHub for new releases</li>
|
||||||
|
<li><strong>2. Download Update:</strong> Downloads the latest release files</li>
|
||||||
|
<li><strong>3. Backup Current Version:</strong> Creates backup of current installation</li>
|
||||||
|
<li><strong>4. Install New Version:</strong> Replaces files and updates dependencies</li>
|
||||||
|
<li><strong>5. Build Application:</strong> Compiles the updated code</li>
|
||||||
|
<li><strong>6. Restart Server:</strong> Stops old server and starts new version</li>
|
||||||
|
<li><strong>7. Reload Page:</strong> Automatically refreshes the browser</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Release Notes</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Click the external link icon next to the update button to view detailed release notes on GitHub.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• See what's new in each version</li>
|
||||||
|
<li>• Read about bug fixes and improvements</li>
|
||||||
|
<li>• Check for any breaking changes</li>
|
||||||
|
<li>• View installation requirements</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-muted/50">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Important Notes</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<li>• <strong>Backup:</strong> Your data and settings are preserved during updates</li>
|
||||||
|
<li>• <strong>Downtime:</strong> Brief downtime occurs during the update process</li>
|
||||||
|
<li>• <strong>Compatibility:</strong> Updates maintain backward compatibility with your data</li>
|
||||||
|
<li>• <strong>Rollback:</strong> If issues occur, you can manually revert to previous version</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground flex items-center gap-2">
|
||||||
|
<HelpCircle className="w-6 h-6" />
|
||||||
|
Help & Documentation
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-[calc(95vh-120px)] sm:h-[calc(90vh-140px)]">
|
||||||
|
{/* Sidebar Navigation */}
|
||||||
|
<div className="w-64 border-r border-border bg-muted/30 overflow-y-auto">
|
||||||
|
<nav className="p-4 space-y-2">
|
||||||
|
{sections.map((section) => {
|
||||||
|
const Icon = section.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={section.id}
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
variant={activeSection === section.id ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-2 text-left"
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{section.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
37
src/app/_components/LoadingModal.tsx
Normal file
37
src/app/_components/LoadingModal.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LoadingModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border p-8">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||||
|
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-card-foreground mb-2">
|
||||||
|
Processing
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{action}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Please wait...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
147
src/app/_components/PublicKeyModal.tsx
Normal file
147
src/app/_components/PublicKeyModal.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Copy, Check, Server, Globe } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
interface PublicKeyModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
publicKey: string;
|
||||||
|
serverName: string;
|
||||||
|
serverIp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
// Try modern clipboard API first
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(publicKey);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} else {
|
||||||
|
// Fallback for older browsers or non-HTTPS
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = publicKey;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
textArea.style.top = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Fallback copy failed:', fallbackError);
|
||||||
|
// If all else fails, show the key in an alert
|
||||||
|
alert('Please manually copy this key:\n\n' + publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
// Fallback: show the key in an alert
|
||||||
|
alert('Please manually copy this key:\n\n' + publicKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
|
<Server className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-card-foreground">SSH Public Key</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Add this key to your server's authorized_keys</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Server Info */}
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Server className="h-4 w-4" />
|
||||||
|
<span className="font-medium">{serverName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
<span>{serverIp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-foreground">Instructions:</h3>
|
||||||
|
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
|
||||||
|
<li>Copy the public key below</li>
|
||||||
|
<li>SSH into your server: <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
|
||||||
|
<li>Add the key to authorized_keys: <code className="bg-muted px-1 rounded">echo "<paste-key>" >> ~/.ssh/authorized_keys</code></li>
|
||||||
|
<li>Set proper permissions: <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Public Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-foreground">Public Key:</label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={publicKey}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[120px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||||
|
placeholder="Public key will appear here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/app/_components/ReleaseNotesModal.tsx
Normal file
218
src/app/_components/ReleaseNotesModal.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
interface ReleaseNotesModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
highlightVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Release {
|
||||||
|
tagName: string;
|
||||||
|
name: string;
|
||||||
|
publishedAt: string;
|
||||||
|
htmlUrl: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for localStorage
|
||||||
|
const getLastSeenVersion = (): string | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem('LAST_SEEN_RELEASE_VERSION');
|
||||||
|
};
|
||||||
|
|
||||||
|
const markVersionAsSeen = (version: string): void => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem('LAST_SEEN_RELEASE_VERSION', version);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: ReleaseNotesModalProps) {
|
||||||
|
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
|
||||||
|
const { data: releasesData, isLoading, error } = api.version.getAllReleases.useQuery(undefined, {
|
||||||
|
enabled: isOpen
|
||||||
|
});
|
||||||
|
const { data: versionData } = api.version.getCurrentVersion.useQuery(undefined, {
|
||||||
|
enabled: isOpen
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current version when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && versionData?.success && versionData.version) {
|
||||||
|
setCurrentVersion(versionData.version);
|
||||||
|
}
|
||||||
|
}, [isOpen, versionData]);
|
||||||
|
|
||||||
|
// Mark version as seen when modal closes
|
||||||
|
const handleClose = () => {
|
||||||
|
if (currentVersion) {
|
||||||
|
markVersionAsSeen(currentVersion);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const releases: Release[] = releasesData?.success ? releasesData.releases ?? [] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Tag className="h-6 w-6 text-blue-600" />
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">Release Notes</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
<span className="text-muted-foreground">Loading release notes...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error || !releasesData?.success ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive mb-2">Failed to load release notes</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{releasesData?.error ?? 'Please try again later'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : releases.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<p className="text-muted-foreground">No releases found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
{releases.map((release, index) => {
|
||||||
|
const isHighlighted = highlightVersion && release.tagName.replace('v', '') === highlightVersion;
|
||||||
|
const isLatest = index === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={release.tagName}
|
||||||
|
className={`border rounded-lg p-6 ${
|
||||||
|
isHighlighted
|
||||||
|
? 'border-blue-500 bg-blue-50/10 dark:bg-blue-950/10'
|
||||||
|
: 'border-border bg-card'
|
||||||
|
} ${isLatest ? 'ring-2 ring-primary/20' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Release Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-xl font-semibold text-card-foreground">
|
||||||
|
{release.name || release.tagName}
|
||||||
|
</h3>
|
||||||
|
{isLatest && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
Latest
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isHighlighted && (
|
||||||
|
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||||
|
New
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
<span>{release.tagName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{new Date(release.publishedAt).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={release.htmlUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="View on GitHub"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Release Body */}
|
||||||
|
{release.body && (
|
||||||
|
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
h1: ({children}) => <h1 className="text-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>,
|
||||||
|
h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>,
|
||||||
|
h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>,
|
||||||
|
p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>,
|
||||||
|
ul: ({children}) => <ul className="list-disc list-inside text-card-foreground mb-3 space-y-1">{children}</ul>,
|
||||||
|
ol: ({children}) => <ol className="list-decimal list-inside text-card-foreground mb-3 space-y-1">{children}</ol>,
|
||||||
|
li: ({children}) => <li className="text-card-foreground">{children}</li>,
|
||||||
|
a: ({href, children}) => <a href={href} className="text-blue-500 hover:text-blue-400 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
|
||||||
|
strong: ({children}) => <strong className="font-semibold text-card-foreground">{children}</strong>,
|
||||||
|
em: ({children}) => <em className="italic text-card-foreground">{children}</em>,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{release.body}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-t border-border bg-muted/30">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{currentVersion && (
|
||||||
|
<span>Current version: <span className="font-medium text-card-foreground">v{currentVersion}</span></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleClose} variant="default">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export helper functions for use in other components
|
||||||
|
export { getLastSeenVersion, markVersionAsSeen };
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
|
|
||||||
export function ResyncButton() {
|
export function ResyncButton() {
|
||||||
const [isResyncing, setIsResyncing] = useState(false);
|
const [isResyncing, setIsResyncing] = useState(false);
|
||||||
@@ -44,27 +45,30 @@ export function ResyncButton() {
|
|||||||
Sync scripts with ProxmoxVE repo
|
Sync scripts with ProxmoxVE repo
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
onClick={handleResync}
|
<Button
|
||||||
disabled={isResyncing}
|
onClick={handleResync}
|
||||||
variant="outline"
|
disabled={isResyncing}
|
||||||
size="default"
|
variant="outline"
|
||||||
className="inline-flex items-center"
|
size="default"
|
||||||
>
|
className="inline-flex items-center"
|
||||||
{isResyncing ? (
|
>
|
||||||
<>
|
{isResyncing ? (
|
||||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
<>
|
||||||
<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-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<svg className="w-5 h-5 mr-2" 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" />
|
||||||
<span>Sync Json Files</span>
|
</svg>
|
||||||
</>
|
<span>Sync Json Files</span>
|
||||||
)}
|
</>
|
||||||
</Button>
|
)}
|
||||||
|
</Button>
|
||||||
|
<ContextualHelpIcon section="sync-button" tooltip="Help with Sync Button" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{lastSync && (
|
{lastSync && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
|
|||||||
204
src/app/_components/SSHKeyInput.tsx
Normal file
204
src/app/_components/SSHKeyInput.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
interface SSHKeyInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHKeyInputProps) {
|
||||||
|
const [inputMode, setInputMode] = useState<'upload' | 'paste'>('upload');
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const validateSSHKey = (keyContent: string): boolean => {
|
||||||
|
const trimmed = keyContent.trim();
|
||||||
|
return (
|
||||||
|
trimmed.includes('BEGIN') &&
|
||||||
|
trimmed.includes('PRIVATE KEY') &&
|
||||||
|
trimmed.includes('END') &&
|
||||||
|
trimmed.includes('PRIVATE KEY')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (file: File) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const content = e.target?.result as string;
|
||||||
|
if (validateSSHKey(content)) {
|
||||||
|
onChange(content);
|
||||||
|
onError?.('');
|
||||||
|
} else {
|
||||||
|
onError?.('Invalid SSH key format. Please ensure the file contains a valid private key.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
onError?.('Failed to read the file. Please try again.');
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
const file = event.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasteChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const content = event.target.value;
|
||||||
|
onChange(content);
|
||||||
|
|
||||||
|
if (content.trim() && !validateSSHKey(content)) {
|
||||||
|
onError?.('Invalid SSH key format. Please ensure the content is a valid private key.');
|
||||||
|
} else {
|
||||||
|
onError?.('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKeyFingerprint = (keyContent: string): string => {
|
||||||
|
// This is a simplified fingerprint - in a real implementation,
|
||||||
|
// you might want to use a library to generate proper SSH key fingerprints
|
||||||
|
if (!keyContent.trim()) return '';
|
||||||
|
|
||||||
|
const lines = keyContent.trim().split('\n');
|
||||||
|
const keyLine = lines.find(line =>
|
||||||
|
line.includes('BEGIN') && line.includes('PRIVATE KEY')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keyLine) {
|
||||||
|
let keyType = 'Unknown';
|
||||||
|
|
||||||
|
// Check for traditional PEM format keys
|
||||||
|
if (keyLine.includes('RSA')) {
|
||||||
|
keyType = 'RSA';
|
||||||
|
} else if (keyLine.includes('ED25519')) {
|
||||||
|
keyType = 'ED25519';
|
||||||
|
} else if (keyLine.includes('ECDSA')) {
|
||||||
|
keyType = 'ECDSA';
|
||||||
|
} else if (keyLine.includes('OPENSSH PRIVATE KEY')) {
|
||||||
|
// For OpenSSH format keys, try to detect type from the key content
|
||||||
|
// This is a heuristic - OpenSSH ED25519 keys typically start with specific patterns
|
||||||
|
// We'll default to "OpenSSH" for now since we can't reliably detect the type
|
||||||
|
keyType = 'OpenSSH';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${keyType} key (${keyContent.length} characters)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown key type';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={inputMode === 'upload' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setInputMode('upload')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={inputMode === 'paste' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setInputMode('paste')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Paste Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Upload Mode */}
|
||||||
|
{inputMode === 'upload' && (
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||||
|
isDragOver
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa,ed25519,id_rsa,id_ed25519,id_ecdsa,*"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-lg">📁</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Drag and drop your SSH private key here, or click to browse
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, ed25519, etc.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paste Mode */}
|
||||||
|
{inputMode === 'paste' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">
|
||||||
|
Paste your SSH private key:
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={handlePasteChange}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABFwAAAAdzc2gtcn... -----END OPENSSH PRIVATE KEY-----"
|
||||||
|
className="w-full h-32 px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring font-mono text-xs"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key Information */}
|
||||||
|
{value && (
|
||||||
|
<div className="p-3 bg-muted rounded-md">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium">Key detected:</span> {getKeyFingerprint(value)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
⚠️ Keep your private keys secure. This key will be stored in the database.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,20 +8,49 @@ import { TypeBadge, UpdateableBadge } from './Badge';
|
|||||||
interface ScriptCardProps {
|
interface ScriptCardProps {
|
||||||
script: ScriptCard;
|
script: ScriptCard;
|
||||||
onClick: (script: ScriptCard) => void;
|
onClick: (script: ScriptCard) => void;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onToggleSelect?: (slug: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
const handleImageError = () => {
|
const handleImageError = () => {
|
||||||
setImageError(true);
|
setImageError(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onToggleSelect && script.slug) {
|
||||||
|
onToggleSelect(script.slug);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col"
|
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
|
||||||
onClick={() => onClick(script)}
|
onClick={() => onClick(script)}
|
||||||
>
|
>
|
||||||
|
{/* Checkbox in top-left corner */}
|
||||||
|
{onToggleSelect && (
|
||||||
|
<div className="absolute top-2 left-2 z-10">
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary border-primary text-primary-foreground'
|
||||||
|
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
onClick={handleCheckboxClick}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-6 flex-1 flex flex-col">
|
<div className="p-6 flex-1 flex flex-col">
|
||||||
{/* Header with logo and name */}
|
{/* Header with logo and name */}
|
||||||
<div className="flex items-start space-x-4 mb-4">
|
<div className="flex items-start space-x-4 mb-4">
|
||||||
@@ -49,7 +78,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
|||||||
</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 flex-wrap gap-1">
|
||||||
<TypeBadge type={script.type ?? 'unknown'} />
|
<TypeBadge type={script.type ?? 'unknown'} />
|
||||||
{script.updateable && <UpdateableBadge />}
|
{script.updateable && <UpdateableBadge />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
193
src/app/_components/ScriptCardList.tsx
Normal file
193
src/app/_components/ScriptCardList.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import type { ScriptCard } from '~/types/script';
|
||||||
|
import { TypeBadge, UpdateableBadge } from './Badge';
|
||||||
|
|
||||||
|
interface ScriptCardListProps {
|
||||||
|
script: ScriptCard;
|
||||||
|
onClick: (script: ScriptCard) => void;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onToggleSelect?: (slug: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
setImageError(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onToggleSelect && script.slug) {
|
||||||
|
onToggleSelect(script.slug);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString?: string) => {
|
||||||
|
if (!dateString) return 'Unknown';
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryNames = () => {
|
||||||
|
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
|
||||||
|
return script.categoryNames.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative"
|
||||||
|
onClick={() => onClick(script)}
|
||||||
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
{onToggleSelect && (
|
||||||
|
<div className="absolute top-4 left-4 z-10">
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary border-primary text-primary-foreground'
|
||||||
|
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
onClick={handleCheckboxClick}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}>
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{script.logo && !imageError ? (
|
||||||
|
<Image
|
||||||
|
src={script.logo}
|
||||||
|
alt={`${script.name} logo`}
|
||||||
|
width={56}
|
||||||
|
height={56}
|
||||||
|
className="w-14 h-14 rounded-lg object-contain"
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-muted-foreground text-lg font-semibold">
|
||||||
|
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Header Row */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
|
||||||
|
{script.name || 'Unnamed Script'}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-3 flex-wrap gap-2">
|
||||||
|
<TypeBadge type={script.type ?? 'unknown'} />
|
||||||
|
{script.updateable && <UpdateableBadge />}
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
|
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}></div>
|
||||||
|
<span className={`text-sm font-medium ${
|
||||||
|
script.isDownloaded ? 'text-green-700' : 'text-destructive'
|
||||||
|
}`}>
|
||||||
|
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Website link */}
|
||||||
|
{script.website && (
|
||||||
|
<a
|
||||||
|
href={script.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1 ml-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span>Website</span>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
|
||||||
|
{script.description || 'No description available'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Metadata Row */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
<span>Categories: {getCategoryNames()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>Created: {formatDate(script.date_created)}</span>
|
||||||
|
</div>
|
||||||
|
{(script.os ?? script.version) && (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{script.os && script.version
|
||||||
|
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
|
||||||
|
: script.os
|
||||||
|
? script.os.charAt(0).toUpperCase() + script.os.slice(1)
|
||||||
|
: script.version
|
||||||
|
? `Version ${script.version}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{script.interface_port && (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>Port: {script.interface_port}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>ID: {script.slug || 'unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -120,9 +120,6 @@ export function ScriptDetailModal({
|
|||||||
// Pass execution mode and server info to the parent
|
// Pass execution mode and server info to the parent
|
||||||
onInstallScript(scriptPath, scriptName, mode, server);
|
onInstallScript(scriptPath, scriptName, mode, server);
|
||||||
|
|
||||||
// Scroll to top of the page to see the terminal
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
|
|
||||||
onClose(); // Close the modal when starting installation
|
onClose(); // Close the modal when starting installation
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -136,38 +133,63 @@ export function ScriptDetailModal({
|
|||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/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-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border">
|
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-border p-6">
|
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
{script.logo && !imageError ? (
|
{script.logo && !imageError ? (
|
||||||
<Image
|
<Image
|
||||||
src={script.logo}
|
src={script.logo}
|
||||||
alt={`${script.name} logo`}
|
alt={`${script.name} logo`}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className="h-16 w-16 rounded-lg object-contain"
|
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-muted">
|
<div className="flex h-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0">
|
||||||
<span className="text-2xl font-semibold text-muted-foreground">
|
<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-foreground">
|
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
|
||||||
{script.name}
|
{script.name}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-1 flex items-center space-x-2">
|
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||||
<TypeBadge type={script.type} />
|
<TypeBadge type={script.type} />
|
||||||
{script.updateable && <UpdateableBadge />}
|
{script.updateable && <UpdateableBadge />}
|
||||||
{script.privileged && <PrivilegedBadge />}
|
{script.privileged && <PrivilegedBadge />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 sm:h-6 sm:w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
|
||||||
{/* Install Button - only show if script files exist */}
|
{/* Install Button - only show if script files exist */}
|
||||||
{scriptFilesData?.success &&
|
{scriptFilesData?.success &&
|
||||||
scriptFilesData.ctExists &&
|
scriptFilesData.ctExists &&
|
||||||
@@ -176,7 +198,7 @@ export function ScriptDetailModal({
|
|||||||
onClick={handleInstallScript}
|
onClick={handleInstallScript}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="flex items-center space-x-2"
|
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -202,7 +224,7 @@ export function ScriptDetailModal({
|
|||||||
onClick={handleViewScript}
|
onClick={handleViewScript}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="flex items-center space-x-2 "
|
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -335,128 +357,107 @@ export function ScriptDetailModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
<Button
|
|
||||||
onClick={onClose}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Load Message */}
|
|
||||||
{loadMessage && (
|
|
||||||
<div className="mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
|
||||||
{loadMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Script Files Status */}
|
|
||||||
{(scriptFilesLoading || comparisonLoading) && (
|
|
||||||
<div className="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="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
|
||||||
<span>Loading script status...</span>
|
|
||||||
</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 (
|
|
||||||
<div className="mx-6 mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
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">
|
|
||||||
Files: {scriptFilesData.files.join(", ")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||||
|
{/* Script Files Status */}
|
||||||
|
{(scriptFilesLoading || comparisonLoading) && (
|
||||||
|
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||||
|
<span>Loading script status...</span>
|
||||||
|
</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 (
|
||||||
|
<div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-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>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Load Message */}
|
||||||
|
{loadMessage && (
|
||||||
|
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||||
|
{loadMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Description
|
Description
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-sm sm:text-base text-muted-foreground">
|
||||||
{script.description}
|
{script.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Basic Information
|
Basic Information
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
@@ -508,7 +509,7 @@ export function ScriptDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Links
|
Links
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
@@ -555,24 +556,24 @@ export function ScriptDetailModal({
|
|||||||
script.type !== "pve" &&
|
script.type !== "pve" &&
|
||||||
script.type !== "addon" && (
|
script.type !== "addon" && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Install Methods
|
Install Methods
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{script.install_methods.map((method, index) => (
|
{script.install_methods.map((method, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="rounded-lg border border-border bg-card p-4"
|
className="rounded-lg border border-border bg-card p-3 sm:p-4"
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
|
||||||
<h4 className="font-medium text-foreground capitalize">
|
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
|
||||||
{method.type}
|
{method.type}
|
||||||
</h4>
|
</h4>
|
||||||
<span className="font-mono text-sm text-muted-foreground">
|
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
|
||||||
{method.script}
|
{method.script}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium text-muted-foreground">
|
<dt className="font-medium text-muted-foreground">
|
||||||
CPU
|
CPU
|
||||||
@@ -616,7 +617,7 @@ export function ScriptDetailModal({
|
|||||||
{(script.default_credentials.username ??
|
{(script.default_credentials.username ??
|
||||||
script.default_credentials.password) && (
|
script.default_credentials.password) && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Default Credentials
|
Default Credentials
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
|
|||||||
370
src/app/_components/ScriptInstallationCard.tsx
Normal file
370
src/app/_components/ScriptInstallationCard.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { StatusBadge } from './Badge';
|
||||||
|
import { getContrastColor } from '../../lib/colorUtils';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from './ui/dropdown-menu';
|
||||||
|
|
||||||
|
interface InstalledScript {
|
||||||
|
id: number;
|
||||||
|
script_name: string;
|
||||||
|
script_path: string;
|
||||||
|
container_id: string | null;
|
||||||
|
server_id: number | null;
|
||||||
|
server_name: string | null;
|
||||||
|
server_ip: string | null;
|
||||||
|
server_user: string | null;
|
||||||
|
server_password: string | null;
|
||||||
|
server_auth_type: string | null;
|
||||||
|
server_ssh_key: string | null;
|
||||||
|
server_ssh_key_passphrase: string | null;
|
||||||
|
server_ssh_port: number | null;
|
||||||
|
server_color: string | null;
|
||||||
|
installation_date: string;
|
||||||
|
status: 'in_progress' | 'success' | 'failed';
|
||||||
|
output_log: string | null;
|
||||||
|
execution_mode: 'local' | 'ssh';
|
||||||
|
container_status?: 'running' | 'stopped' | 'unknown';
|
||||||
|
web_ui_ip: string | null;
|
||||||
|
web_ui_port: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScriptInstallationCardProps {
|
||||||
|
script: InstalledScript;
|
||||||
|
isEditing: boolean;
|
||||||
|
editFormData: { script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string };
|
||||||
|
onInputChange: (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
onShell: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
isUpdating: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
|
// New container control props
|
||||||
|
containerStatus?: 'running' | 'stopped' | 'unknown';
|
||||||
|
onStartStop: (action: 'start' | 'stop') => void;
|
||||||
|
onDestroy: () => void;
|
||||||
|
isControlling: boolean;
|
||||||
|
// Web UI props
|
||||||
|
onOpenWebUI: () => void;
|
||||||
|
onAutoDetectWebUI: () => void;
|
||||||
|
isAutoDetecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScriptInstallationCard({
|
||||||
|
script,
|
||||||
|
isEditing,
|
||||||
|
editFormData,
|
||||||
|
onInputChange,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onUpdate,
|
||||||
|
onShell,
|
||||||
|
onDelete,
|
||||||
|
isUpdating,
|
||||||
|
isDeleting,
|
||||||
|
containerStatus,
|
||||||
|
onStartStop,
|
||||||
|
onDestroy,
|
||||||
|
isControlling,
|
||||||
|
onOpenWebUI,
|
||||||
|
onAutoDetectWebUI,
|
||||||
|
isAutoDetecting
|
||||||
|
}: ScriptInstallationCardProps) {
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if a script has any actions available
|
||||||
|
const hasActions = (script: InstalledScript) => {
|
||||||
|
if (script.container_id && script.execution_mode === 'ssh') return true;
|
||||||
|
if (script.web_ui_ip != null) return true;
|
||||||
|
if (!script.container_id || script.execution_mode !== 'ssh') return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
|
||||||
|
>
|
||||||
|
{/* Header with Script Name and Status */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editFormData.script_name}
|
||||||
|
onChange={(e) => onInputChange('script_name', e.target.value)}
|
||||||
|
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="Script name"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">{script.script_path}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-foreground truncate">{script.script_name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{script.script_path}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 flex-shrink-0">
|
||||||
|
<StatusBadge status={script.status}>
|
||||||
|
{script.status.replace('_', ' ').toUpperCase()}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details Grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-3 mb-4">
|
||||||
|
{/* Container ID */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">Container ID</div>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editFormData.container_id}
|
||||||
|
onChange={(e) => onInputChange('container_id', e.target.value)}
|
||||||
|
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="Container ID"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm font-mono text-foreground break-all">
|
||||||
|
{script.container_id ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span>{script.container_id}</span>
|
||||||
|
{script.container_status && (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
|
script.container_status === 'running' ? 'bg-green-500' :
|
||||||
|
script.container_status === 'stopped' ? 'bg-red-500' :
|
||||||
|
'bg-gray-400'
|
||||||
|
}`}></div>
|
||||||
|
<span className={`text-xs font-medium ${
|
||||||
|
script.container_status === 'running' ? 'text-green-700 dark:text-green-300' :
|
||||||
|
script.container_status === 'stopped' ? 'text-red-700 dark:text-red-300' :
|
||||||
|
'text-gray-500 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{script.container_status === 'running' ? 'Running' :
|
||||||
|
script.container_status === 'stopped' ? 'Stopped' :
|
||||||
|
'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : '-'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Web UI */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">IP:PORT</div>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editFormData.web_ui_ip}
|
||||||
|
onChange={(e) => onInputChange('web_ui_ip', e.target.value)}
|
||||||
|
className="flex-1 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="IP"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editFormData.web_ui_port}
|
||||||
|
onChange={(e) => onInputChange('web_ui_port', e.target.value)}
|
||||||
|
className="w-20 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="Port"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm font-mono text-foreground">
|
||||||
|
{script.web_ui_ip ? (
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<button
|
||||||
|
onClick={onOpenWebUI}
|
||||||
|
disabled={containerStatus === 'stopped'}
|
||||||
|
className={`text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline flex-shrink-0 ${
|
||||||
|
containerStatus === 'stopped' ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{script.web_ui_ip}:{script.web_ui_port ?? 80}
|
||||||
|
</button>
|
||||||
|
{script.container_id && script.execution_mode === 'ssh' && (
|
||||||
|
<button
|
||||||
|
onClick={onAutoDetectWebUI}
|
||||||
|
disabled={isAutoDetecting}
|
||||||
|
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors flex-shrink-0 ml-2"
|
||||||
|
title="Re-detect IP and port"
|
||||||
|
>
|
||||||
|
{isAutoDetecting ? '...' : 'Re-detect'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
{script.container_id && script.execution_mode === 'ssh' && (
|
||||||
|
<button
|
||||||
|
onClick={onAutoDetectWebUI}
|
||||||
|
disabled={isAutoDetecting}
|
||||||
|
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors"
|
||||||
|
title="Re-detect IP and port"
|
||||||
|
>
|
||||||
|
{isAutoDetecting ? '...' : 'Re-detect'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
|
||||||
|
<span
|
||||||
|
className="text-sm px-3 py-1 rounded inline-block"
|
||||||
|
style={{
|
||||||
|
backgroundColor: script.server_color ?? 'transparent',
|
||||||
|
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{script.server_name ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Installation Date */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">Installation Date</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(String(script.installation_date))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isUpdating}
|
||||||
|
variant="save"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
{isUpdating ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onCancel}
|
||||||
|
variant="cancel"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={onEdit}
|
||||||
|
variant="edit"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{hasActions(script) && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 min-w-0 bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-48 bg-gray-900 border-gray-700">
|
||||||
|
{script.container_id && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onUpdate}
|
||||||
|
disabled={containerStatus === 'stopped'}
|
||||||
|
className="text-cyan-300 hover:text-cyan-200 hover:bg-cyan-900/20 focus:bg-cyan-900/20"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{script.container_id && script.execution_mode === 'ssh' && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onShell}
|
||||||
|
disabled={containerStatus === 'stopped'}
|
||||||
|
className="text-gray-300 hover:text-gray-200 hover:bg-gray-800/20 focus:bg-gray-800/20"
|
||||||
|
>
|
||||||
|
Shell
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{script.web_ui_ip && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onOpenWebUI}
|
||||||
|
disabled={containerStatus === 'stopped'}
|
||||||
|
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
|
||||||
|
>
|
||||||
|
Open UI
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{script.container_id && script.execution_mode === 'ssh' && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator className="bg-gray-700" />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onStartStop(containerStatus === 'running' ? 'stop' : 'start')}
|
||||||
|
disabled={isControlling || containerStatus === 'unknown'}
|
||||||
|
className={containerStatus === 'running'
|
||||||
|
? "text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||||
|
: "text-green-300 hover:text-green-200 hover:bg-green-900/20 focus:bg-green-900/20"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onDestroy}
|
||||||
|
disabled={isControlling}
|
||||||
|
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||||
|
>
|
||||||
|
{isControlling ? 'Working...' : 'Destroy'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator className="bg-gray-700" />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { ScriptCard } from './ScriptCard';
|
import { ScriptCard } from './ScriptCard';
|
||||||
|
import { ScriptCardList } from './ScriptCardList';
|
||||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||||
import { CategorySidebar } from './CategorySidebar';
|
import { CategorySidebar } from './CategorySidebar';
|
||||||
import { FilterBar, type FilterState } from './FilterBar';
|
import { FilterBar, type FilterState } from './FilterBar';
|
||||||
|
import { ViewToggle } from './ViewToggle';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||||
|
|
||||||
@@ -19,6 +21,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||||
|
const [selectedSlugs, setSelectedSlugs] = useState<Set<string>>(new Set());
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number; currentScript: string; failed: Array<{ slug: string; error: string }> } | null>(null);
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
showUpdatable: null,
|
showUpdatable: null,
|
||||||
@@ -26,15 +31,109 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
});
|
});
|
||||||
|
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||||
|
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getCtScripts.useQuery();
|
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||||
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||||
{ slug: selectedSlug ?? '' },
|
{ slug: selectedSlug ?? '' },
|
||||||
{ enabled: !!selectedSlug }
|
{ enabled: !!selectedSlug }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Individual script download mutation
|
||||||
|
const loadSingleScriptMutation = api.scripts.loadScript.useMutation();
|
||||||
|
|
||||||
|
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
// Load SAVE_FILTER setting
|
||||||
|
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||||
|
let saveFilterEnabled = false;
|
||||||
|
if (saveFilterResponse.ok) {
|
||||||
|
const saveFilterData = await saveFilterResponse.json();
|
||||||
|
saveFilterEnabled = saveFilterData.enabled ?? false;
|
||||||
|
setSaveFiltersEnabled(saveFilterEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved filters if SAVE_FILTER is enabled
|
||||||
|
if (saveFilterEnabled) {
|
||||||
|
const filtersResponse = await fetch('/api/settings/filters');
|
||||||
|
if (filtersResponse.ok) {
|
||||||
|
const filtersData = await filtersResponse.json();
|
||||||
|
if (filtersData.filters) {
|
||||||
|
setFilters(filtersData.filters as FilterState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load view mode
|
||||||
|
const viewModeResponse = await fetch('/api/settings/view-mode');
|
||||||
|
if (viewModeResponse.ok) {
|
||||||
|
const viewModeData = await viewModeResponse.json();
|
||||||
|
const viewMode = viewModeData.viewMode;
|
||||||
|
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
|
||||||
|
setViewMode(viewMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingFilters(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save filters when they change (if SAVE_FILTER is enabled)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!saveFiltersEnabled || isLoadingFilters) return;
|
||||||
|
|
||||||
|
const saveFilters = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings/filters', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filters }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving filters:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
const timeoutId = setTimeout(() => void saveFilters(), 500);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||||
|
|
||||||
|
// Save view mode when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoadingFilters) return;
|
||||||
|
|
||||||
|
const saveViewMode = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings/view-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ viewMode }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving view mode:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
const timeoutId = setTimeout(() => void saveViewMode(), 300);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [viewMode, isLoadingFilters]);
|
||||||
|
|
||||||
// Extract categories from metadata
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
@@ -234,6 +333,167 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
setSearchQuery(newFilters.searchQuery);
|
setSearchQuery(newFilters.searchQuery);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Selection management functions
|
||||||
|
const toggleScriptSelection = (slug: string) => {
|
||||||
|
setSelectedSlugs(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(slug)) {
|
||||||
|
newSet.delete(slug);
|
||||||
|
} else {
|
||||||
|
newSet.add(slug);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllVisible = () => {
|
||||||
|
const visibleSlugs = new Set(filteredScripts.map(script => script.slug).filter(Boolean));
|
||||||
|
setSelectedSlugs(visibleSlugs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectedSlugs(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFriendlyErrorMessage = (error: string, slug: string): string => {
|
||||||
|
const errorLower = error.toLowerCase();
|
||||||
|
|
||||||
|
// Exact matches first (most specific)
|
||||||
|
if (error === 'Script not found') {
|
||||||
|
return `Script "${slug}" is not available for download. It may not exist in the repository or has been removed.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error === 'Failed to load script') {
|
||||||
|
return `Unable to download script "${slug}". Please check your internet connection and try again.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network/Connection errors
|
||||||
|
if (errorLower.includes('network') || errorLower.includes('connection') || errorLower.includes('timeout')) {
|
||||||
|
return 'Network connection failed. Please check your internet connection and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHub API errors
|
||||||
|
if (errorLower.includes('not found') || errorLower.includes('404')) {
|
||||||
|
return `Script "${slug}" not found in the repository. It may have been removed or renamed.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes('rate limit') || errorLower.includes('403')) {
|
||||||
|
return 'GitHub API rate limit exceeded. Please wait a few minutes and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes('unauthorized') || errorLower.includes('401')) {
|
||||||
|
return 'Access denied. The script may be private or require authentication.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// File system errors
|
||||||
|
if (errorLower.includes('permission') || errorLower.includes('eacces')) {
|
||||||
|
return 'Permission denied. Please check file system permissions.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes('no space') || errorLower.includes('enospc')) {
|
||||||
|
return 'Insufficient disk space. Please free up some space and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes('read-only') || errorLower.includes('erofs')) {
|
||||||
|
return 'Cannot write to read-only file system. Please check your installation directory.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script-specific errors
|
||||||
|
if (errorLower.includes('script not found')) {
|
||||||
|
return `Script "${slug}" not found in the local scripts directory.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes('invalid script') || errorLower.includes('malformed')) {
|
||||||
|
return `Script "${slug}" appears to be corrupted or invalid.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes('already exists') || errorLower.includes('file exists')) {
|
||||||
|
return `Script "${slug}" already exists locally. Skipping download.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic fallbacks
|
||||||
|
if (errorLower.includes('timeout')) {
|
||||||
|
return 'Download timed out. The script may be too large or the connection is slow.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes('server error') || errorLower.includes('500')) {
|
||||||
|
return 'Server error occurred. Please try again later.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't categorize it, return a more helpful generic message
|
||||||
|
if (error.length > 100) {
|
||||||
|
return `Download failed: ${error.substring(0, 100)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Download failed: ${error}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadScriptsIndividually = async (slugsToDownload: string[]) => {
|
||||||
|
setDownloadProgress({ current: 0, total: slugsToDownload.length, currentScript: '', failed: [] });
|
||||||
|
|
||||||
|
const successful: Array<{ slug: string; files: string[] }> = [];
|
||||||
|
const failed: Array<{ slug: string; error: string }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < slugsToDownload.length; i++) {
|
||||||
|
const slug = slugsToDownload[i];
|
||||||
|
|
||||||
|
// Update progress with current script
|
||||||
|
setDownloadProgress(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
current: i,
|
||||||
|
currentScript: slug ?? ''
|
||||||
|
} : null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Download individual script
|
||||||
|
const result = await loadSingleScriptMutation.mutateAsync({ slug: slug ?? '' });
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
successful.push({ slug: slug ?? '', files: result.files ?? [] });
|
||||||
|
} else {
|
||||||
|
const error = 'error' in result ? result.error : 'Failed to load script';
|
||||||
|
const userFriendlyError = getFriendlyErrorMessage(error, slug ?? '');
|
||||||
|
failed.push({ slug: slug ?? '', error: userFriendlyError });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to load script';
|
||||||
|
const userFriendlyError = getFriendlyErrorMessage(errorMessage, slug ?? '');
|
||||||
|
failed.push({
|
||||||
|
slug: slug ?? '',
|
||||||
|
error: userFriendlyError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final progress update
|
||||||
|
setDownloadProgress(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
current: slugsToDownload.length,
|
||||||
|
failed
|
||||||
|
} : null);
|
||||||
|
|
||||||
|
// Clear selection and refetch to update card download status
|
||||||
|
setSelectedSlugs(new Set());
|
||||||
|
void refetch();
|
||||||
|
|
||||||
|
// Keep progress bar visible until user navigates away or manually dismisses
|
||||||
|
// Progress bar will stay visible to show final results
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchDownload = () => {
|
||||||
|
const slugsToDownload = Array.from(selectedSlugs);
|
||||||
|
if (slugsToDownload.length > 0) {
|
||||||
|
void downloadScriptsIndividually(slugsToDownload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAllFiltered = () => {
|
||||||
|
const slugsToDownload = filteredScripts.map(script => script.slug).filter(Boolean);
|
||||||
|
if (slugsToDownload.length > 0) {
|
||||||
|
void downloadScriptsIndividually(slugsToDownload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle category selection with auto-scroll
|
// Handle category selection with auto-scroll
|
||||||
const handleCategorySelect = (category: string | null) => {
|
const handleCategorySelect = (category: string | null) => {
|
||||||
setSelectedCategory(category);
|
setSelectedCategory(category);
|
||||||
@@ -254,6 +514,18 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
}
|
}
|
||||||
}, [selectedCategory]);
|
}, [selectedCategory]);
|
||||||
|
|
||||||
|
// Clear selection when switching between card/list views
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedSlugs(new Set());
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
|
// Clear progress bar when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setDownloadProgress(null);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const handleCardClick = (scriptCard: { slug: string }) => {
|
const handleCardClick = (scriptCard: { slug: string }) => {
|
||||||
// All scripts are GitHub scripts, open modal
|
// All scripts are GitHub scripts, open modal
|
||||||
@@ -299,7 +571,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!scriptsWithStatus || scriptsWithStatus.length === 0) {
|
if (!scriptsWithStatus?.length) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
@@ -316,9 +588,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6">
|
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||||
{/* Category Sidebar */}
|
{/* Category Sidebar */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||||
<CategorySidebar
|
<CategorySidebar
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryCounts={categoryCounts}
|
categoryCounts={categoryCounts}
|
||||||
@@ -329,7 +601,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 min-w-0" ref={gridRef}>
|
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
||||||
{/* Enhanced Filter Bar */}
|
{/* Enhanced Filter Bar */}
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -337,8 +609,167 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
totalScripts={scriptsWithStatus.length}
|
totalScripts={scriptsWithStatus.length}
|
||||||
filteredCount={filteredScripts.length}
|
filteredCount={filteredScripts.length}
|
||||||
updatableCount={filterCounts.updatableCount}
|
updatableCount={filterCounts.updatableCount}
|
||||||
|
saveFiltersEnabled={saveFiltersEnabled}
|
||||||
|
isLoadingFilters={isLoadingFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<ViewToggle
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{selectedSlugs.size > 0 ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleBatchDownload}
|
||||||
|
disabled={loadSingleScriptMutation.isPending}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/30 text-blue-300 hover:text-blue-200 hover:border-blue-400/50"
|
||||||
|
>
|
||||||
|
{loadSingleScriptMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
|
||||||
|
Downloading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Download Selected (${selectedSlugs.size})`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadAllFiltered}
|
||||||
|
disabled={filteredScripts.length === 0 || loadSingleScriptMutation.isPending}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loadSingleScriptMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
|
||||||
|
Downloading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Download All Filtered (${filteredScripts.length})`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSlugs.size > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={clearSelection}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
Clear Selection
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredScripts.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={selectAllVisible}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
Select All Visible
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{downloadProgress && (
|
||||||
|
<div className="mb-4 p-4 bg-card border border-border rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{downloadProgress.current >= downloadProgress.total ? 'Download completed' : 'Downloading scripts'}... {downloadProgress.current} of {downloadProgress.total}
|
||||||
|
</span>
|
||||||
|
{downloadProgress.currentScript && downloadProgress.current < downloadProgress.total && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Currently downloading: {downloadProgress.currentScript}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{Math.round((downloadProgress.current / downloadProgress.total) * 100)}%
|
||||||
|
</span>
|
||||||
|
{downloadProgress.current >= downloadProgress.total && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDownloadProgress(null)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Dismiss progress bar"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="w-full bg-muted rounded-full h-2 mb-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ease-out ${
|
||||||
|
downloadProgress.failed.length > 0 ? 'bg-yellow-500' : 'bg-primary'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${(downloadProgress.current / downloadProgress.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Visualization */}
|
||||||
|
<div className="flex items-center text-xs text-muted-foreground mb-2">
|
||||||
|
<span className="mr-2">Progress:</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Array.from({ length: downloadProgress.total }, (_, i) => {
|
||||||
|
const isCompleted = i < downloadProgress.current;
|
||||||
|
const isCurrent = i === downloadProgress.current;
|
||||||
|
const isFailed = downloadProgress.failed.some(f => f.slug === downloadProgress.currentScript);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`px-1 py-0.5 rounded text-xs ${
|
||||||
|
isCompleted
|
||||||
|
? isFailed ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||||
|
: isCurrent
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 animate-pulse'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCompleted ? (isFailed ? '✗' : '✓') : isCurrent ? '⟳' : '○'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Failed Scripts Details */}
|
||||||
|
{downloadProgress.failed.length > 0 && (
|
||||||
|
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<svg className="w-4 h-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
|
Failed Downloads ({downloadProgress.failed.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{downloadProgress.failed.map((failed, index) => (
|
||||||
|
<div key={index} className="text-xs text-red-700 dark:text-red-300">
|
||||||
|
<span className="font-medium">{failed.slug}:</span> {failed.error}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||||
<div className="hidden mb-8">
|
<div className="hidden mb-8">
|
||||||
<div className="relative max-w-md mx-auto">
|
<div className="relative max-w-md mx-auto">
|
||||||
@@ -414,25 +845,51 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
viewMode === 'card' ? (
|
||||||
{filteredScripts.map((script, index) => {
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
// Add validation to ensure script has required properties
|
{filteredScripts.map((script, index) => {
|
||||||
if (!script || typeof script !== 'object') {
|
// Add validation to ensure script has required properties
|
||||||
return null;
|
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}`;
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
return (
|
|
||||||
<ScriptCard
|
return (
|
||||||
key={uniqueKey}
|
<ScriptCard
|
||||||
script={script}
|
key={uniqueKey}
|
||||||
onClick={handleCardClick}
|
script={script}
|
||||||
/>
|
onClick={handleCardClick}
|
||||||
);
|
isSelected={selectedSlugs.has(script.slug ?? '')}
|
||||||
})}
|
onToggleSelect={toggleScriptSelection}
|
||||||
</div>
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredScripts.map((script, index) => {
|
||||||
|
// Add validation to ensure script has required properties
|
||||||
|
if (!script || typeof script !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScriptCardList
|
||||||
|
key={uniqueKey}
|
||||||
|
script={script}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
isSelected={selectedSlugs.has(script.slug ?? '')}
|
||||||
|
onToggleSelect={toggleScriptSelection}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScriptDetailModal
|
<ScriptDetailModal
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { CreateServerData } from '../../types/server';
|
import type { CreateServerData } from '../../types/server';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { SSHKeyInput } from './SSHKeyInput';
|
||||||
|
import { PublicKeyModal } from './PublicKeyModal';
|
||||||
|
import { Key } from 'lucide-react';
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
onSubmit: (data: CreateServerData) => void;
|
onSubmit: (data: CreateServerData) => void;
|
||||||
@@ -18,13 +21,40 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
ip: '',
|
ip: '',
|
||||||
user: '',
|
user: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
auth_type: 'password',
|
||||||
|
ssh_key: '',
|
||||||
|
ssh_key_passphrase: '',
|
||||||
|
ssh_port: 22,
|
||||||
|
color: '#3b82f6',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Partial<CreateServerData>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
|
||||||
|
const [sshKeyError, setSshKeyError] = useState<string>('');
|
||||||
|
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||||
|
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
|
||||||
|
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
|
||||||
|
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
|
||||||
|
const [, setIsGeneratedKey] = useState(false);
|
||||||
|
const [, setGeneratedServerId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColorCodingSetting = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/color-coding');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setColorCodingEnabled(Boolean(data.enabled));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading color coding setting:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadColorCodingSetting();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: Partial<CreateServerData> = {};
|
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
newErrors.name = 'Server name is required';
|
newErrors.name = 'Server name is required';
|
||||||
@@ -44,12 +74,29 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
newErrors.user = 'Username is required';
|
newErrors.user = 'Username is required';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.password.trim()) {
|
// Validate SSH port
|
||||||
newErrors.password = 'Password is required';
|
if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
|
||||||
|
newErrors.ssh_port = 'SSH port must be between 1 and 65535';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate authentication based on auth_type
|
||||||
|
const authType = formData.auth_type ?? 'password';
|
||||||
|
|
||||||
|
if (authType === 'password') {
|
||||||
|
if (!formData.password?.trim()) {
|
||||||
|
newErrors.password = 'Password is required for password authentication';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === 'key') {
|
||||||
|
if (!formData.ssh_key?.trim()) {
|
||||||
|
newErrors.ssh_key = 'SSH key is required for key authentication';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -57,24 +104,90 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
if (validateForm()) {
|
if (validateForm()) {
|
||||||
onSubmit(formData);
|
onSubmit(formData);
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
setFormData({ name: '', ip: '', user: '', password: '' });
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
ip: '',
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
auth_type: 'password',
|
||||||
|
ssh_key: '',
|
||||||
|
ssh_key_passphrase: '',
|
||||||
|
ssh_port: 22,
|
||||||
|
color: '#3b82f6'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (field: keyof CreateServerData) => (
|
const handleChange = (field: keyof CreateServerData) => (
|
||||||
e: React.ChangeEvent<HTMLInputElement>
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||||
) => {
|
) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
// Clear error when user starts typing
|
// Clear error when user starts typing
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset generated key state when switching auth types
|
||||||
|
if (field === 'auth_type') {
|
||||||
|
setIsGeneratedKey(false);
|
||||||
|
setGeneratedPublicKey('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateKeyPair = async () => {
|
||||||
|
setIsGeneratingKey(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/servers/generate-keypair', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to generate key pair');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const serverId = data.serverId ?? 0;
|
||||||
|
const keyPath = `data/ssh-keys/server_${serverId}_key`;
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
ssh_key: data.privateKey ?? '',
|
||||||
|
ssh_key_path: keyPath,
|
||||||
|
key_generated: 1
|
||||||
|
}));
|
||||||
|
setGeneratedPublicKey(data.publicKey ?? '');
|
||||||
|
setGeneratedServerId(serverId);
|
||||||
|
setIsGeneratedKey(true);
|
||||||
|
setShowPublicKeyModal(true);
|
||||||
|
setSshKeyError('');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error ?? 'Failed to generate key pair');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating key pair:', error);
|
||||||
|
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingKey(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSSHKeyChange = (value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, ssh_key: value }));
|
||||||
|
if (errors.ssh_key) {
|
||||||
|
setErrors(prev => ({ ...prev, ssh_key: undefined }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
Server Name *
|
Server Name *
|
||||||
@@ -126,6 +239,63 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
|
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
SSH Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="ssh_port"
|
||||||
|
value={formData.ssh_port ?? 22}
|
||||||
|
onChange={handleChange('ssh_port')}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
|
errors.ssh_port ? 'border-destructive' : 'border-border'
|
||||||
|
}`}
|
||||||
|
placeholder="22"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
/>
|
||||||
|
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
Authentication Type *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="auth_type"
|
||||||
|
value={formData.auth_type ?? 'password'}
|
||||||
|
onChange={handleChange('auth_type')}
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||||
|
>
|
||||||
|
<option value="password">Password Only</option>
|
||||||
|
<option value="key">SSH Key Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{colorCodingEnabled && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
Server Color
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="color"
|
||||||
|
value={formData.color ?? '#3b82f6'}
|
||||||
|
onChange={handleChange('color')}
|
||||||
|
className="w-20 h-10 rounded cursor-pointer border border-border"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Choose a color to identify this server
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Authentication */}
|
||||||
|
{formData.auth_type === 'password' && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
Password *
|
Password *
|
||||||
@@ -133,7 +303,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
value={formData.password}
|
value={formData.password ?? ''}
|
||||||
onChange={handleChange('password')}
|
onChange={handleChange('password')}
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
errors.password ? 'border-destructive' : 'border-border'
|
errors.password ? 'border-destructive' : 'border-border'
|
||||||
@@ -142,15 +312,87 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
/>
|
/>
|
||||||
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
|
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4">
|
{/* SSH Key Authentication */}
|
||||||
|
{formData.auth_type === 'key' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium text-muted-foreground">
|
||||||
|
SSH Private Key *
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerateKeyPair}
|
||||||
|
disabled={isGeneratingKey}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Key className="h-4 w-4" />
|
||||||
|
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show manual key input only if no key has been generated */}
|
||||||
|
{!formData.key_generated && (
|
||||||
|
<>
|
||||||
|
<SSHKeyInput
|
||||||
|
value={formData.ssh_key ?? ''}
|
||||||
|
onChange={handleSSHKeyChange}
|
||||||
|
onError={setSshKeyError}
|
||||||
|
/>
|
||||||
|
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
||||||
|
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show generated key status */}
|
||||||
|
{formData.key_generated && (
|
||||||
|
<div className="p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
SSH key pair generated successfully
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||||
|
The private key has been generated and will be saved with the server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
SSH Key Passphrase (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="ssh_key_passphrase"
|
||||||
|
value={formData.ssh_key_passphrase ?? ''}
|
||||||
|
onChange={handleChange('ssh_key_passphrase')}
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||||
|
placeholder="Enter passphrase for encrypted key"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Only required if your SSH key is encrypted with a passphrase
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
||||||
{isEditing && onCancel && (
|
{isEditing && onCancel && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
|
className="w-full sm:w-auto order-2 sm:order-1"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -159,11 +401,22 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="default"
|
variant="default"
|
||||||
size="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>
|
||||||
|
|
||||||
|
{/* Public Key Modal */}
|
||||||
|
<PublicKeyModal
|
||||||
|
isOpen={showPublicKeyModal}
|
||||||
|
onClose={() => setShowPublicKeyModal(false)}
|
||||||
|
publicKey={generatedPublicKey}
|
||||||
|
serverName={formData.name || 'New Server'}
|
||||||
|
serverIp={formData.ip}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ 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';
|
import { Button } from './ui/button';
|
||||||
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
|
import { PublicKeyModal } from './PublicKeyModal';
|
||||||
|
import { Key } from 'lucide-react';
|
||||||
|
|
||||||
interface ServerListProps {
|
interface ServerListProps {
|
||||||
servers: Server[];
|
servers: Server[];
|
||||||
@@ -15,6 +18,20 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
|
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
|
||||||
const [connectionResults, setConnectionResults] = useState<Map<number, { success: boolean; message: string }>>(new Map());
|
const [connectionResults, setConnectionResults] = useState<Map<number, { success: boolean; message: string }>>(new Map());
|
||||||
|
const [confirmationModal, setConfirmationModal] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
variant: 'danger';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
} | null>(null);
|
||||||
|
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
|
||||||
|
const [publicKeyData, setPublicKeyData] = useState<{
|
||||||
|
publicKey: string;
|
||||||
|
serverName: string;
|
||||||
|
serverIp: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const handleEdit = (server: Server) => {
|
const handleEdit = (server: Server) => {
|
||||||
setEditingId(server.id);
|
setEditingId(server.id);
|
||||||
@@ -31,12 +48,49 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
const handleViewPublicKey = async (server: Server) => {
|
||||||
if (window.confirm('Are you sure you want to delete this server configuration?')) {
|
try {
|
||||||
onDelete(id);
|
const response = await fetch(`/api/servers/${server.id}/public-key`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to retrieve public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { success: boolean; publicKey?: string; serverName?: string; serverIp?: string; error?: string };
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setPublicKeyData({
|
||||||
|
publicKey: data.publicKey ?? '',
|
||||||
|
serverName: data.serverName ?? '',
|
||||||
|
serverIp: data.serverIp ?? ''
|
||||||
|
});
|
||||||
|
setShowPublicKeyModal(true);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error ?? 'Failed to retrieve public key');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving public key:', error);
|
||||||
|
// You could show a toast notification here
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
const server = servers.find(s => s.id === id);
|
||||||
|
if (!server) return;
|
||||||
|
|
||||||
|
setConfirmationModal({
|
||||||
|
isOpen: true,
|
||||||
|
variant: 'danger',
|
||||||
|
title: 'Delete Server',
|
||||||
|
message: `This will permanently delete the server configuration "${server.name}" (${server.ip}) and all associated installed scripts. This action cannot be undone!`,
|
||||||
|
confirmText: server.name,
|
||||||
|
onConfirm: () => {
|
||||||
|
onDelete(id);
|
||||||
|
setConfirmationModal(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleTestConnection = async (server: Server) => {
|
const handleTestConnection = async (server: Server) => {
|
||||||
setTestingConnections(prev => new Set(prev).add(server.id));
|
setTestingConnections(prev => new Set(prev).add(server.id));
|
||||||
setConnectionResults(prev => {
|
setConnectionResults(prev => {
|
||||||
@@ -85,7 +139,11 @@ 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-card border border-border rounded-lg p-4 shadow-sm">
|
<div
|
||||||
|
key={server.id}
|
||||||
|
className="bg-card border border-border rounded-lg p-4 shadow-sm"
|
||||||
|
style={{ borderLeft: `4px solid ${server.color ?? 'transparent'}` }}
|
||||||
|
>
|
||||||
{editingId === server.id ? (
|
{editingId === server.id ? (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
|
<h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
|
||||||
@@ -95,6 +153,11 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
ip: server.ip,
|
ip: server.ip,
|
||||||
user: server.user,
|
user: server.user,
|
||||||
password: server.password,
|
password: server.password,
|
||||||
|
auth_type: server.auth_type,
|
||||||
|
ssh_key: server.ssh_key,
|
||||||
|
ssh_key_passphrase: server.ssh_key_passphrase,
|
||||||
|
ssh_port: server.ssh_port,
|
||||||
|
color: server.color,
|
||||||
}}
|
}}
|
||||||
onSubmit={handleUpdate}
|
onSubmit={handleUpdate}
|
||||||
isEditing={true}
|
isEditing={true}
|
||||||
@@ -102,30 +165,30 @@ 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-foreground 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-muted-foreground">
|
<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-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
@@ -162,56 +225,105 @@ 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)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
|
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)}
|
{/* View Public Key button - only show for generated keys */}
|
||||||
variant="outline"
|
{server.key_generated === 1 && (
|
||||||
size="sm"
|
<Button
|
||||||
>
|
onClick={() => handleViewPublicKey(server)}
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
variant="outline"
|
||||||
<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" />
|
size="sm"
|
||||||
</svg>
|
className="flex-1 sm:flex-none border-blue-500/20 text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
|
||||||
Edit
|
>
|
||||||
</Button>
|
<Key className="w-4 h-4 mr-1" />
|
||||||
<Button
|
<span className="hidden sm:inline">View Public Key</span>
|
||||||
onClick={() => handleDelete(server.id)}
|
<span className="sm:hidden">Key</span>
|
||||||
variant="outline"
|
</Button>
|
||||||
size="sm"
|
)}
|
||||||
className="border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
|
<Button
|
||||||
>
|
onClick={() => handleEdit(server)}
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
variant="outline"
|
||||||
<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" />
|
size="sm"
|
||||||
</svg>
|
className="flex-1 sm:flex-none"
|
||||||
Delete
|
>
|
||||||
</Button>
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Edit</span>
|
||||||
|
<span className="sm:hidden">✏️</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDelete(server.id)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 sm:flex-none border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Delete</span>
|
||||||
|
<span className="sm:hidden">🗑️</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
{confirmationModal && (
|
||||||
|
<ConfirmationModal
|
||||||
|
isOpen={confirmationModal.isOpen}
|
||||||
|
onClose={() => setConfirmationModal(null)}
|
||||||
|
onConfirm={confirmationModal.onConfirm}
|
||||||
|
title={confirmationModal.title}
|
||||||
|
message={confirmationModal.message}
|
||||||
|
variant={confirmationModal.variant}
|
||||||
|
confirmText={confirmationModal.confirmText}
|
||||||
|
confirmButtonText="Delete Server"
|
||||||
|
cancelButtonText="Cancel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Public Key Modal */}
|
||||||
|
{publicKeyData && (
|
||||||
|
<PublicKeyModal
|
||||||
|
isOpen={showPublicKeyModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowPublicKeyModal(false);
|
||||||
|
setPublicKeyData(null);
|
||||||
|
}}
|
||||||
|
publicKey={publicKeyData.publicKey}
|
||||||
|
serverName={publicKeyData.serverName}
|
||||||
|
serverIp={publicKeyData.serverIp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/app/_components/ServerSettingsButton.tsx
Normal file
50
src/app/_components/ServerSettingsButton.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { SettingsModal } from './SettingsModal';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
export function ServerSettingsButton() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
|
Add and manage PVE Servers:
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="inline-flex items-center"
|
||||||
|
title="Add PVE Server"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Manage PVE Servers
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { SettingsModal } from './SettingsModal';
|
import { GeneralSettingsModal } from './GeneralSettingsModal';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { Settings } from 'lucide-react';
|
||||||
|
|
||||||
export function SettingsButton() {
|
export function SettingsButton() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -11,41 +12,21 @@ export function SettingsButton() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="text-sm text-muted-foreground font-medium">
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
Add and manage PVE Servers:
|
Application Settings:
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="inline-flex items-center"
|
className="inline-flex items-center"
|
||||||
title="Add PVE Server"
|
title="Open Settings"
|
||||||
>
|
>
|
||||||
<svg
|
<Settings className="w-5 h-5 mr-2" />
|
||||||
className="w-5 h-5 mr-2"
|
Settings
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Manage PVE Servers
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
<GeneralSettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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';
|
import { Button } from './ui/button';
|
||||||
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -15,7 +16,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -32,7 +32,11 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
throw new Error('Failed to fetch servers');
|
throw new Error('Failed to fetch servers');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setServers(data as Server[]);
|
// Sort servers by name alphabetically
|
||||||
|
const sortedServers = (data as Server[]).sort((a, b) =>
|
||||||
|
(a.name ?? '').localeCompare(b.name ?? '')
|
||||||
|
);
|
||||||
|
setServers(sortedServers);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -99,102 +103,67 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/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-card 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-border">
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||||
<h2 className="text-2xl font-bold text-card-foreground">Settings</h2>
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||||
|
<ContextualHelpIcon section="server-settings" tooltip="Help with Server Settings" />
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="flex space-x-8 px-6">
|
|
||||||
<Button
|
|
||||||
onClick={() => setActiveTab('servers')}
|
|
||||||
variant="ghost"
|
|
||||||
size="null"
|
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
activeTab === 'servers'
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Server Settings
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setActiveTab('general')}
|
|
||||||
variant="ghost"
|
|
||||||
size="null"
|
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
activeTab === 'general'
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
General
|
|
||||||
</Button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-4 bg-destructive/10 border border-destructive 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' && (
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">Server Configurations</h3>
|
|
||||||
<ServerForm onSubmit={handleCreateServer} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">Saved Servers</h3>
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
||||||
<p className="mt-2 text-gray-600">Loading servers...</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ServerList
|
|
||||||
servers={servers}
|
|
||||||
onUpdate={handleUpdateServer}
|
|
||||||
onDelete={handleDeleteServer}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'general' && (
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">General Settings</h3>
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
|
||||||
<p className="text-muted-foreground">General settings will be available in a future update.</p>
|
<ServerForm onSubmit={handleCreateServer} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Saved Servers</h3>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<p className="mt-2 text-gray-600">Loading servers...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ServerList
|
||||||
|
servers={servers}
|
||||||
|
onUpdate={handleUpdateServer}
|
||||||
|
onDelete={handleDeleteServer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
204
src/app/_components/SetupModal.tsx
Normal file
204
src/app/_components/SetupModal.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Toggle } from './ui/toggle';
|
||||||
|
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SetupModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [enableAuth, setEnableAuth] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Only validate passwords if authentication is enabled
|
||||||
|
if (enableAuth && password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: enableAuth ? username : undefined,
|
||||||
|
password: enableAuth ? password : undefined,
|
||||||
|
enabled: enableAuth
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// If authentication is enabled, automatically log in the user
|
||||||
|
if (enableAuth) {
|
||||||
|
const loginResponse = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginResponse.ok) {
|
||||||
|
// Login successful, complete setup
|
||||||
|
onComplete();
|
||||||
|
} else {
|
||||||
|
// Setup succeeded but login failed, still complete setup
|
||||||
|
console.warn('Setup completed but auto-login failed');
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Authentication disabled, just complete setup
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json() as { error: string };
|
||||||
|
setError(errorData.error ?? 'Failed to setup authentication');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup error:', error);
|
||||||
|
setError('Failed to setup authentication');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="h-8 w-8 text-green-600" />
|
||||||
|
<h2 className="text-2xl font-bold text-card-foreground">Setup Authentication</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-muted-foreground text-center mb-6">
|
||||||
|
Set up authentication to secure your application. This will be required for future access.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-username" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="setup-username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Choose a username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required={enableAuth}
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-password" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="setup-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Choose a password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required={enableAuth}
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm-password" className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
required={enableAuth}
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-foreground mb-1">Enable Authentication</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{enableAuth
|
||||||
|
? 'Authentication will be required on every page load'
|
||||||
|
: 'Authentication will be optional (can be enabled later in settings)'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={enableAuth}
|
||||||
|
onCheckedChange={setEnableAuth}
|
||||||
|
disabled={isLoading}
|
||||||
|
label="Enable authentication"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-800 border border-red-200 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
(enableAuth && (!username.trim() || !password.trim() || !confirmPassword.trim()))
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Setting Up...' : 'Complete Setup'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +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 { Button } from './ui/button';
|
||||||
import { Play, Square, Trash2, X } from 'lucide-react';
|
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
@@ -11,6 +11,7 @@ interface TerminalProps {
|
|||||||
mode?: 'local' | 'ssh';
|
mode?: 'local' | 'ssh';
|
||||||
server?: any;
|
server?: any;
|
||||||
isUpdate?: boolean;
|
isUpdate?: boolean;
|
||||||
|
isShell?: boolean;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,45 +21,132 @@ interface TerminalMessage {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, containerId }: TerminalProps) {
|
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
const [mobileInput, setMobileInput] = useState('');
|
||||||
|
const [showMobileInput, setShowMobileInput] = useState(false);
|
||||||
|
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [isStopped, setIsStopped] = useState(false);
|
||||||
|
const [isTerminalReady, setIsTerminalReady] = useState(false);
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
const xtermRef = useRef<any>(null);
|
const xtermRef = useRef<any>(null);
|
||||||
const fitAddonRef = useRef<any>(null);
|
const fitAddonRef = useRef<any>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [executionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
const inputHandlerRef = useRef<((data: string) => void) | null>(null);
|
||||||
|
const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||||
const isConnectingRef = useRef<boolean>(false);
|
const isConnectingRef = useRef<boolean>(false);
|
||||||
const hasConnectedRef = useRef<boolean>(false);
|
const hasConnectedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
|
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
|
||||||
|
|
||||||
|
const handleMessage = useCallback((message: TerminalMessage) => {
|
||||||
|
if (!xtermRef.current) return;
|
||||||
|
|
||||||
|
const timestamp = new Date(message.timestamp).toLocaleTimeString();
|
||||||
|
const prefix = `[${timestamp}] `;
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'start':
|
||||||
|
xtermRef.current.writeln(`${prefix}[START] ${message.data}`);
|
||||||
|
setIsRunning(true);
|
||||||
|
break;
|
||||||
|
case 'output':
|
||||||
|
// Write directly to terminal - xterm.js handles ANSI codes natively
|
||||||
|
xtermRef.current.write(message.data);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
// Check if this looks like ANSI terminal output (contains escape codes)
|
||||||
|
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
|
||||||
|
// This is likely terminal output sent to stderr, treat it as normal output
|
||||||
|
xtermRef.current.write(message.data);
|
||||||
|
} else if (message.data.includes('TERM environment variable not set')) {
|
||||||
|
// This is a common warning, treat as normal output
|
||||||
|
xtermRef.current.write(message.data);
|
||||||
|
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
|
||||||
|
// This is a script error, show it with error prefix
|
||||||
|
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
|
||||||
|
} else {
|
||||||
|
// This is a real error, show it with error prefix
|
||||||
|
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'end':
|
||||||
|
setIsRunning(false);
|
||||||
|
|
||||||
|
// Check if this is an LXC creation script
|
||||||
|
const isLxcCreation = scriptPath.includes('ct/') ||
|
||||||
|
scriptPath.includes('create_lxc') ||
|
||||||
|
(containerId != null) ||
|
||||||
|
scriptName.includes('lxc') ||
|
||||||
|
scriptName.includes('container');
|
||||||
|
|
||||||
|
if (isLxcCreation && message.data.includes('SSH script execution finished with code: 0')) {
|
||||||
|
// Display prominent LXC creation completion message
|
||||||
|
xtermRef.current.writeln('');
|
||||||
|
xtermRef.current.writeln('#########################################');
|
||||||
|
xtermRef.current.writeln('########## LXC CREATION FINISHED ########');
|
||||||
|
xtermRef.current.writeln('#########################################');
|
||||||
|
xtermRef.current.writeln('');
|
||||||
|
} else {
|
||||||
|
xtermRef.current.writeln(`${prefix}✅ ${message.data}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [scriptPath, containerId, scriptName]);
|
||||||
|
|
||||||
// Ensure we're on the client side
|
// Ensure we're on the client side
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true);
|
setIsClient(true);
|
||||||
|
// Detect mobile on mount
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only initialize on client side
|
// Only initialize on client side
|
||||||
if (!isClient || !terminalRef.current || xtermRef.current) return;
|
if (!isClient || !terminalRef.current || xtermRef.current) return;
|
||||||
|
|
||||||
|
// Store ref value to avoid stale closure
|
||||||
|
const terminalElement = terminalRef.current;
|
||||||
|
|
||||||
// Use setTimeout to ensure DOM is fully ready
|
// Use setTimeout to ensure DOM is fully ready
|
||||||
const initTerminal = async () => {
|
const initTerminal = async () => {
|
||||||
if (!terminalRef.current || xtermRef.current) return;
|
if (!terminalElement || xtermRef.current) return;
|
||||||
|
|
||||||
// Dynamically import xterm modules to avoid SSR issues
|
// Dynamically import xterm modules to avoid SSR issues
|
||||||
const { Terminal: XTerm } = await import('@xterm/xterm');
|
const { Terminal: XTerm } = await import('@xterm/xterm');
|
||||||
const { FitAddon } = await import('@xterm/addon-fit');
|
const { FitAddon } = await import('@xterm/addon-fit');
|
||||||
const { WebLinksAddon } = await import('@xterm/addon-web-links');
|
const { WebLinksAddon } = await import('@xterm/addon-web-links');
|
||||||
|
|
||||||
|
// Use the mobile state
|
||||||
|
|
||||||
const terminal = new XTerm({
|
const terminal = new XTerm({
|
||||||
theme: {
|
theme: {
|
||||||
background: '#000000',
|
background: '#0d1117',
|
||||||
foreground: '#00ff00',
|
foreground: '#e6edf3',
|
||||||
cursor: '#00ff00',
|
cursor: '#58a6ff',
|
||||||
|
cursorAccent: '#0d1117',
|
||||||
|
// Let ANSI colors work naturally - only define basic colors
|
||||||
|
black: '#484f58',
|
||||||
|
red: '#f85149',
|
||||||
|
green: '#3fb950',
|
||||||
|
yellow: '#d29922',
|
||||||
|
blue: '#58a6ff',
|
||||||
|
magenta: '#bc8cff',
|
||||||
|
cyan: '#39d353',
|
||||||
|
white: '#b1bac4',
|
||||||
|
brightBlack: '#6e7681',
|
||||||
|
brightRed: '#ff7b72',
|
||||||
|
brightGreen: '#56d364',
|
||||||
|
brightYellow: '#e3b341',
|
||||||
|
brightBlue: '#79c0ff',
|
||||||
|
brightMagenta: '#d2a8ff',
|
||||||
|
brightCyan: '#56d364',
|
||||||
|
brightWhite: '#f0f6fc',
|
||||||
},
|
},
|
||||||
fontSize: 14,
|
fontSize: isMobile ? 7 : 14,
|
||||||
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: 'block',
|
cursorStyle: 'block',
|
||||||
@@ -70,6 +158,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
|
||||||
@@ -77,41 +171,71 @@ 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);
|
||||||
|
|
||||||
|
// Ensure proper terminal rendering
|
||||||
|
setTimeout(() => {
|
||||||
|
terminal.refresh(0, terminal.rows - 1);
|
||||||
|
// Ensure cursor is properly positioned
|
||||||
|
terminal.focus();
|
||||||
|
|
||||||
|
// Force focus on the terminal element
|
||||||
|
terminalElement.focus();
|
||||||
|
terminalElement.click();
|
||||||
|
|
||||||
|
// Add click handler to ensure terminal stays focused
|
||||||
|
const focusHandler = () => {
|
||||||
|
terminal.focus();
|
||||||
|
terminalElement.focus();
|
||||||
|
};
|
||||||
|
terminalElement.addEventListener('click', focusHandler);
|
||||||
|
|
||||||
|
// Store the handler for cleanup
|
||||||
|
(terminalElement as any).focusHandler = focusHandler;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
// Fit after a small delay to ensure proper sizing
|
// Fit after a small delay to ensure proper sizing
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
|
// Force fit multiple times for mobile to ensure proper sizing
|
||||||
|
if (isMobile) {
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
}, 200);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Store references
|
// Add resize listener for mobile responsiveness
|
||||||
xtermRef.current = terminal;
|
|
||||||
fitAddonRef.current = fitAddon;
|
|
||||||
|
|
||||||
// Handle terminal input
|
|
||||||
terminal.onData((data) => {
|
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
||||||
wsRef.current.send(JSON.stringify({
|
|
||||||
action: 'input',
|
|
||||||
executionId,
|
|
||||||
input: data
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle terminal resize
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (fitAddonRef.current) {
|
if (fitAddonRef.current) {
|
||||||
fitAddonRef.current.fit();
|
setTimeout(() => {
|
||||||
|
fitAddonRef.current.fit();
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Store the handler for cleanup
|
||||||
|
(terminalElement as any).resizeHandler = handleResize;
|
||||||
|
|
||||||
|
// Store references
|
||||||
|
xtermRef.current = terminal;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
|
// Mark terminal as ready
|
||||||
|
setIsTerminalReady(true);
|
||||||
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -121,15 +245,51 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
void initTerminal();
|
void initTerminal();
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (xtermRef.current) {
|
if (terminalElement && (terminalElement as any).resizeHandler) {
|
||||||
xtermRef.current.dispose();
|
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
|
||||||
xtermRef.current = null;
|
}
|
||||||
fitAddonRef.current = null;
|
if (terminalElement && (terminalElement as any).focusHandler) {
|
||||||
|
terminalElement.removeEventListener('click', (terminalElement as any).focusHandler as (this: HTMLDivElement, ev: PointerEvent) => any);
|
||||||
|
}
|
||||||
|
if (xtermRef.current) {
|
||||||
|
xtermRef.current.dispose();
|
||||||
|
xtermRef.current = null;
|
||||||
|
fitAddonRef.current = null;
|
||||||
|
setIsTerminalReady(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isClient, isMobile]);
|
||||||
|
|
||||||
|
// Handle terminal input with current executionId
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTerminalReady || !xtermRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminal = xtermRef.current;
|
||||||
|
|
||||||
|
const handleData = (data: string) => {
|
||||||
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
const message = {
|
||||||
|
action: 'input',
|
||||||
|
executionId,
|
||||||
|
input: data
|
||||||
|
};
|
||||||
|
wsRef.current.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [executionId, isClient]);
|
|
||||||
|
// Store the handler reference
|
||||||
|
inputHandlerRef.current = handleData;
|
||||||
|
terminal.onData(handleData);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Clear the handler reference
|
||||||
|
inputHandlerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [executionId, isTerminalReady]); // Depend on terminal ready state
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prevent multiple connections in React Strict Mode
|
// Prevent multiple connections in React Strict Mode
|
||||||
@@ -144,6 +304,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
}
|
}
|
||||||
|
|
||||||
isConnectingRef.current = true;
|
isConnectingRef.current = true;
|
||||||
|
const isInitialConnection = !hasConnectedRef.current;
|
||||||
hasConnectedRef.current = true;
|
hasConnectedRef.current = true;
|
||||||
|
|
||||||
// Small delay to prevent rapid reconnection
|
// Small delay to prevent rapid reconnection
|
||||||
@@ -159,17 +320,24 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
isConnectingRef.current = false;
|
isConnectingRef.current = false;
|
||||||
|
|
||||||
// Send start message immediately after connection
|
// Only auto-start on initial connection, not on reconnections
|
||||||
const message = {
|
if (isInitialConnection && !isRunning) {
|
||||||
action: 'start',
|
// Generate a new execution ID for the initial run
|
||||||
scriptPath,
|
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
executionId,
|
setExecutionId(newExecutionId);
|
||||||
mode,
|
|
||||||
server,
|
const message = {
|
||||||
isUpdate,
|
action: 'start',
|
||||||
containerId
|
scriptPath,
|
||||||
};
|
executionId: newExecutionId,
|
||||||
ws.send(JSON.stringify(message));
|
mode,
|
||||||
|
server,
|
||||||
|
isUpdate,
|
||||||
|
isShell,
|
||||||
|
containerId
|
||||||
|
};
|
||||||
|
ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -206,55 +374,23 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [scriptPath, executionId, mode, server, isUpdate, containerId]);
|
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleMessage = (message: TerminalMessage) => {
|
|
||||||
if (!xtermRef.current) return;
|
|
||||||
|
|
||||||
const timestamp = new Date(message.timestamp).toLocaleTimeString();
|
|
||||||
const prefix = `[${timestamp}] `;
|
|
||||||
|
|
||||||
switch (message.type) {
|
|
||||||
case 'start':
|
|
||||||
xtermRef.current.writeln(`${prefix}[START] ${message.data}`);
|
|
||||||
setIsRunning(true);
|
|
||||||
break;
|
|
||||||
case 'output':
|
|
||||||
// Write directly to terminal - xterm.js handles ANSI codes natively
|
|
||||||
xtermRef.current.write(message.data);
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
// Check if this looks like ANSI terminal output (contains escape codes)
|
|
||||||
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
|
|
||||||
// This is likely terminal output sent to stderr, treat it as normal output
|
|
||||||
xtermRef.current.write(message.data);
|
|
||||||
} else if (message.data.includes('TERM environment variable not set')) {
|
|
||||||
// This is a common warning, treat as normal output
|
|
||||||
xtermRef.current.write(message.data);
|
|
||||||
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
|
|
||||||
// This is a script error, show it with error prefix
|
|
||||||
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
|
|
||||||
} else {
|
|
||||||
// This is a real error, show it with error prefix
|
|
||||||
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'end':
|
|
||||||
xtermRef.current.writeln(`${prefix}[SUCCESS] ${message.data}`);
|
|
||||||
setIsRunning(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startScript = () => {
|
const startScript = () => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||||
|
// Generate a new execution ID for each script run
|
||||||
|
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
setExecutionId(newExecutionId);
|
||||||
|
|
||||||
|
setIsStopped(false);
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
action: 'start',
|
action: 'start',
|
||||||
scriptPath,
|
scriptPath,
|
||||||
executionId,
|
executionId: newExecutionId,
|
||||||
mode,
|
mode,
|
||||||
server,
|
server,
|
||||||
isUpdate,
|
isUpdate,
|
||||||
|
isShell,
|
||||||
containerId
|
containerId
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -262,6 +398,8 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
|
|
||||||
const stopScript = () => {
|
const stopScript = () => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
setIsStopped(true);
|
||||||
|
setIsRunning(false);
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
action: 'stop',
|
action: 'stop',
|
||||||
executionId
|
executionId
|
||||||
@@ -275,6 +413,30 @@ 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 (
|
||||||
@@ -301,21 +463,21 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
||||||
{/* Terminal Header */}
|
{/* Terminal Header */}
|
||||||
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
|
<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-foreground 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-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs hidden sm:inline">
|
||||||
{isConnected ? 'Connected' : 'Disconnected'}
|
{isConnected ? 'Connected' : 'Disconnected'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -324,22 +486,164 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
{/* Terminal Output */}
|
{/* Terminal Output */}
|
||||||
<div
|
<div
|
||||||
ref={terminalRef}
|
ref={terminalRef}
|
||||||
className="h-[32rem] w-full max-w-4xl mx-auto"
|
className={`h-[16rem] sm:h-[24rem] lg:h-[32rem] w-full max-w-4xl mx-auto ${isMobile ? 'mobile-terminal' : ''}`}
|
||||||
style={{ minHeight: '512px' }}
|
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-muted px-4 py-2 flex items-center justify-between border-t border-border">
|
<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 && !isStopped)}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
|
className={`text-xs sm:text-sm ${isConnected && (!isRunning || isStopped) ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
<Play className="h-4 w-4 mr-1" />
|
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
Start
|
<span className="hidden sm:inline">Start</span>
|
||||||
|
<span className="sm:hidden">▶</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -347,20 +651,22 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
disabled={!isRunning}
|
disabled={!isRunning}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
|
className={`text-xs sm:text-sm ${isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4 mr-1" />
|
<Square className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
Stop
|
<span className="hidden sm:inline">Stop</span>
|
||||||
|
<span className="sm:hidden">⏹</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={clearOutput}
|
onClick={clearOutput}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
className="text-xs sm:text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
Clear
|
<span className="hidden sm:inline">Clear</span>
|
||||||
|
<span className="sm:hidden">🗑</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -368,9 +674,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-gray-600 text-white hover:bg-gray-700"
|
className="text-xs sm:text-sm bg-gray-600 text-white hover:bg-gray-700 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<X className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
|||||||
className="fixed inset-0 backdrop-blur-sm bg-black/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-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border">
|
<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-border">
|
<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">
|
||||||
|
|||||||
@@ -3,40 +3,73 @@
|
|||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ExternalLink, Download, RefreshCw, Loader2, Check } from "lucide-react";
|
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||||
import { useState } from "react";
|
|
||||||
|
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface VersionDisplayProps {
|
||||||
|
onOpenReleaseNotes?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading overlay component with log streaming
|
||||||
|
function LoadingOverlay({
|
||||||
|
isNetworkError = false,
|
||||||
|
logs = []
|
||||||
|
}: {
|
||||||
|
isNetworkError?: boolean;
|
||||||
|
logs?: string[];
|
||||||
|
}) {
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new logs arrive
|
||||||
|
useEffect(() => {
|
||||||
|
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
|
||||||
// Loading overlay component
|
|
||||||
function LoadingOverlay({ isNetworkError = false }: { isNetworkError?: boolean }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-2xl border border-gray-200 dark:border-gray-700 max-w-md mx-4">
|
<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="flex flex-col items-center space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-blue-600 dark:text-blue-400" />
|
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||||
<div className="absolute inset-0 rounded-full border-2 border-blue-200 dark:border-blue-800 animate-pulse"></div>
|
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="text-lg font-semibold text-card-foreground mb-2">
|
||||||
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
|
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground">
|
||||||
{isNetworkError
|
{isNetworkError
|
||||||
? 'The server is restarting after the update...'
|
? 'The server is restarting after the update...'
|
||||||
: 'Please stand by while we update your application...'
|
: 'Please stand by while we update your application...'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
{isNetworkError
|
{isNetworkError
|
||||||
? 'This may take a few moments. The page will reload automatically. You may see a blank page for up to a minute!.'
|
? 'This may take a few moments. The page will reload automatically.'
|
||||||
: 'The server will restart automatically when complete.'
|
: 'The server will restart automatically when complete.'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="flex space-x-1">
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
|
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></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-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,83 +77,129 @@ function LoadingOverlay({ isNetworkError = false }: { isNetworkError?: boolean }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VersionDisplay() {
|
export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {
|
||||||
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
|
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
|
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
|
||||||
const [isNetworkError, setIsNetworkError] = useState(false);
|
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({
|
const executeUpdate = api.version.executeUpdate.useMutation({
|
||||||
onSuccess: (result: any) => {
|
onSuccess: (result) => {
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = updateStartTime ? now - updateStartTime : 0;
|
|
||||||
|
|
||||||
|
|
||||||
setUpdateResult({ success: result.success, message: result.message });
|
setUpdateResult({ success: result.success, message: result.message });
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// The script now runs independently, so we show a longer overlay
|
// Start subscribing to update logs
|
||||||
// and wait for the server to restart
|
setShouldSubscribe(true);
|
||||||
setIsNetworkError(true);
|
setUpdateLogs(['Update started...']);
|
||||||
setUpdateResult({ success: true, message: 'Update in progress... Server will restart automatically.' });
|
|
||||||
|
|
||||||
// Wait longer for the update to complete and server to restart
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsUpdating(false);
|
|
||||||
setIsNetworkError(false);
|
|
||||||
// Try to reload after the update completes
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 10000); // 10 seconds to allow for update completion
|
|
||||||
}, 5000); // Show overlay for 5 seconds
|
|
||||||
} else {
|
} else {
|
||||||
// For errors, show for at least 1 second
|
setIsUpdating(false);
|
||||||
const remainingTime = Math.max(0, 1000 - elapsed);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}, remainingTime);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const now = Date.now();
|
setUpdateResult({ success: false, message: error.message });
|
||||||
const elapsed = updateStartTime ? now - updateStartTime : 0;
|
setIsUpdating(false);
|
||||||
|
|
||||||
// Check if this is a network error (expected during server restart)
|
|
||||||
const isNetworkError = error.message.includes('Failed to fetch') ||
|
|
||||||
error.message.includes('NetworkError') ||
|
|
||||||
error.message.includes('fetch') ||
|
|
||||||
error.message.includes('network');
|
|
||||||
|
|
||||||
if (isNetworkError && elapsed < 60000) { // If it's a network error within 30 seconds, treat as success
|
|
||||||
setIsNetworkError(true);
|
|
||||||
setUpdateResult({ success: true, message: 'Update in progress... Server is restarting.' });
|
|
||||||
|
|
||||||
// Wait longer for server to come back up
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsUpdating(false);
|
|
||||||
setIsNetworkError(false);
|
|
||||||
// Try to reload after a longer delay
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 5000);
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
|
||||||
// For real errors, show for at least 1 second
|
|
||||||
setUpdateResult({ success: false, message: error.message });
|
|
||||||
const remainingTime = Math.max(0, 1000 - elapsed);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}, remainingTime);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Poll for update logs
|
||||||
|
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
|
||||||
|
enabled: shouldSubscribe,
|
||||||
|
refetchInterval: 1000, // Poll every second
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update logs when data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateLogsData?.success && updateLogsData.logs) {
|
||||||
|
lastLogTimeRef.current = Date.now();
|
||||||
|
setUpdateLogs(updateLogsData.logs);
|
||||||
|
|
||||||
|
if (updateLogsData.isComplete) {
|
||||||
|
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
||||||
|
setIsNetworkError(true);
|
||||||
|
// Start reconnection attempts when we know update is complete
|
||||||
|
startReconnectAttempts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [updateLogsData]);
|
||||||
|
|
||||||
|
// Monitor for server connection loss and auto-reload (fallback only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldSubscribe) return;
|
||||||
|
|
||||||
|
// Only use this as a fallback - the main trigger should be completion detection
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
|
||||||
|
|
||||||
|
// Only start reconnection if we've been updating for at least 3 minutes
|
||||||
|
// and no logs for 60 seconds (very conservative fallback)
|
||||||
|
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
||||||
|
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
||||||
|
|
||||||
|
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
|
||||||
|
setIsNetworkError(true);
|
||||||
|
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
||||||
|
|
||||||
|
// Start trying to reconnect
|
||||||
|
startReconnectAttempts();
|
||||||
|
}
|
||||||
|
}, 10000); // Check every 10 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(checkInterval);
|
||||||
|
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
|
||||||
|
|
||||||
|
// Attempt to reconnect and reload page when server is back
|
||||||
|
const startReconnectAttempts = () => {
|
||||||
|
if (reconnectIntervalRef.current) return;
|
||||||
|
|
||||||
|
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
|
||||||
|
|
||||||
|
reconnectIntervalRef.current = setInterval(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
// Try to fetch the root path to check if server is back
|
||||||
|
const response = await fetch('/', { method: 'HEAD' });
|
||||||
|
if (response.ok || response.status === 200) {
|
||||||
|
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
|
||||||
|
|
||||||
|
// Clear interval and reload
|
||||||
|
if (reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server still down, keep trying
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup reconnect interval on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
setUpdateResult(null);
|
setUpdateResult(null);
|
||||||
setIsNetworkError(false);
|
setIsNetworkError(false);
|
||||||
|
setUpdateLogs([]);
|
||||||
|
setShouldSubscribe(false);
|
||||||
setUpdateStartTime(Date.now());
|
setUpdateStartTime(Date.now());
|
||||||
|
lastLogTimeRef.current = Date.now();
|
||||||
executeUpdate.mutate();
|
executeUpdate.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,69 +231,63 @@ export function VersionDisplay() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Loading overlay */}
|
{/* Loading overlay */}
|
||||||
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} />}
|
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
||||||
<Badge variant={isUpToDate ? "default" : "secondary"}>
|
<Badge
|
||||||
|
variant={isUpToDate ? "default" : "secondary"}
|
||||||
|
className={`text-xs ${onOpenReleaseNotes ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
|
||||||
|
onClick={onOpenReleaseNotes}
|
||||||
|
>
|
||||||
v{currentVersion}
|
v{currentVersion}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{updateAvailable && releaseInfo && (
|
{updateAvailable && releaseInfo && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
|
||||||
<div className="relative group">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="destructive" className="animate-pulse cursor-help">
|
<Button
|
||||||
Update Available
|
onClick={handleUpdate}
|
||||||
</Badge>
|
disabled={isUpdating}
|
||||||
<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">
|
size="sm"
|
||||||
<div className="text-center">
|
variant="destructive"
|
||||||
<div className="font-semibold mb-1">How to update:</div>
|
className="text-xs h-6 px-2"
|
||||||
<div>Click the button to update</div>
|
>
|
||||||
<div>or update manually:</div>
|
{isUpdating ? (
|
||||||
<div>cd $PVESCRIPTLOCAL_DIR</div>
|
<>
|
||||||
<div>git pull</div>
|
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||||
<div>npm install</div>
|
<span className="hidden sm:inline">Updating...</span>
|
||||||
<div>npm run build</div>
|
<span className="sm:hidden">...</span>
|
||||||
<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>
|
<Download className="h-3 w-3 mr-1" />
|
||||||
|
<span className="hidden sm:inline">Update Now</span>
|
||||||
|
<span className="sm:hidden">Update</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ContextualHelpIcon section="update-system" tooltip="Help with updates" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
onClick={handleUpdate}
|
<span className="text-xs text-muted-foreground">Release Notes:</span>
|
||||||
disabled={isUpdating}
|
<a
|
||||||
size="sm"
|
href={releaseInfo.htmlUrl}
|
||||||
variant="destructive"
|
target="_blank"
|
||||||
className="text-xs h-6 px-2"
|
rel="noopener noreferrer"
|
||||||
>
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
{isUpdating ? (
|
title="View latest release"
|
||||||
<>
|
>
|
||||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
Updating...
|
</a>
|
||||||
</>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="h-3 w-3 mr-1" />
|
|
||||||
Update Now
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{updateResult && (
|
{updateResult && (
|
||||||
<div className={`text-xs px-2 py-1 rounded ${
|
<div className={`text-xs px-2 py-1 rounded text-center ${
|
||||||
updateResult.success
|
updateResult.success
|
||||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
|
||||||
: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
: 'bg-destructive/20 text-destructive border border-destructive/30'
|
||||||
}`}>
|
}`}>
|
||||||
{updateResult.message}
|
{updateResult.message}
|
||||||
</div>
|
</div>
|
||||||
@@ -223,9 +296,8 @@ export function VersionDisplay() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isUpToDate && (
|
{isUpToDate && (
|
||||||
<span className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
<span className="text-xs text-chart-2">
|
||||||
<Check className="h-3 w-3" />
|
✓ Up to date
|
||||||
Up to date
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
45
src/app/_components/ViewToggle.tsx
Normal file
45
src/app/_components/ViewToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Grid3X3, List } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ViewToggleProps {
|
||||||
|
viewMode: 'card' | 'list';
|
||||||
|
onViewModeChange: (mode: 'card' | 'list') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="flex items-center space-x-1 bg-muted rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
onClick={() => onViewModeChange('card')}
|
||||||
|
variant={viewMode === 'card' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className={`flex items-center space-x-2 ${
|
||||||
|
viewMode === 'card'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="h-4 w-4" />
|
||||||
|
<span className="text-sm">Card View</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className={`flex items-center space-x-2 ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
<span className="text-sm">List View</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,16 @@ const buttonVariants = cva(
|
|||||||
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-left after:scale-x-100 hover:after:origin-bottom-right hover:after:scale-x-0 after:transition-transform after:ease-in-out after:duration-300",
|
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-left after:scale-x-100 hover:after:origin-bottom-right hover:after:scale-x-0 after:transition-transform after:ease-in-out after:duration-300",
|
||||||
linkHover2:
|
linkHover2:
|
||||||
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-right after:scale-x-0 hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:ease-in-out after:duration-300",
|
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-right after:scale-x-0 hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:ease-in-out after:duration-300",
|
||||||
|
// Dark theme action button variants
|
||||||
|
edit: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
update: "bg-cyan-900/20 hover:bg-cyan-900/30 border border-cyan-700/50 text-cyan-300 hover:text-cyan-200 hover:border-cyan-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
shell: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
openui: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
start: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
stop: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
delete: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
|
||||||
|
save: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
|
||||||
|
cancel: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
|
|||||||
198
src/app/_components/ui/dropdown-menu.tsx
Normal file
198
src/app/_components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
23
src/app/_components/ui/input.tsx
Normal file
23
src/app/_components/ui/input.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "../../../lib/utils"
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
41
src/app/_components/ui/toggle.tsx
Normal file
41
src/app/_components/ui/toggle.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "../../../lib/utils"
|
||||||
|
|
||||||
|
export interface ToggleProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||||
|
checked?: boolean;
|
||||||
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
|
||||||
|
({ className, checked, onCheckedChange, label, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div className={cn(
|
||||||
|
"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out peer-checked:bg-blue-600 transition-colors duration-300 ease-in-out",
|
||||||
|
checked && "bg-blue-600 after:translate-x-full",
|
||||||
|
className
|
||||||
|
)} />
|
||||||
|
</label>
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Toggle.displayName = "Toggle"
|
||||||
|
|
||||||
|
export { Toggle }
|
||||||
66
src/app/api/auth/login/route.ts
Normal file
66
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { comparePassword, generateToken, getAuthConfig } from '~/lib/auth';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await request.json() as { username: string; password: string };
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
|
||||||
|
if (!authConfig.hasCredentials) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authentication not configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username !== authConfig.username) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await comparePassword(password, authConfig.passwordHash!);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken(username);
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Login successful',
|
||||||
|
username
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set httpOnly cookie
|
||||||
|
response.cookies.set('auth-token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during login:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/api/auth/setup/route.ts
Normal file
94
src/app/api/auth/setup/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { updateAuthCredentials, getAuthConfig, setSetupCompleted } from '~/lib/auth';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password, enabled } = await request.json() as { username?: string; password?: string; enabled?: boolean };
|
||||||
|
|
||||||
|
// If authentication is disabled, we don't need any credentials
|
||||||
|
if (enabled === false) {
|
||||||
|
// Just set AUTH_ENABLED to false without storing credentials
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set setup completed flag
|
||||||
|
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
|
||||||
|
if (setupCompletedRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any empty AUTH_USERNAME or AUTH_PASSWORD_HASH lines
|
||||||
|
envContent = envContent.replace(/^AUTH_USERNAME=\s*$/m, '');
|
||||||
|
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=\s*$/m, '');
|
||||||
|
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||||
|
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Authentication disabled successfully'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If authentication is enabled, require username and password
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username is required when authentication is enabled' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username must be at least 3 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || password.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password must be at least 6 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if credentials already exist
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
if (authConfig.hasCredentials) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authentication is already configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAuthCredentials(username, password, enabled ?? true);
|
||||||
|
setSetupCompleted();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Authentication setup completed successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during setup:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/auth/verify/route.ts
Normal file
37
src/app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { verifyToken } from '~/lib/auth';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = request.cookies.get('auth-token')?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No token provided' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
|
||||||
|
if (!decoded) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid token' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
username: decoded.username,
|
||||||
|
authenticated: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying token:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/app/api/servers/[id]/public-key/route.ts
Normal file
64
src/app/api/servers/[id]/public-key/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDatabase } from '../../../../../server/database';
|
||||||
|
import { getSSHService } from '../../../../../server/ssh-service';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = parseInt(idParam);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid server ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const server = db.getServerById(id);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow viewing public key if it was generated by the system
|
||||||
|
if (!(server as any).key_generated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Public key not available for user-provided keys' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(server as any).ssh_key_path) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH key path not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshService = getSSHService();
|
||||||
|
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
publicKey,
|
||||||
|
serverName: (server as any).name,
|
||||||
|
serverIp: (server as any).ip
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving public key:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,16 +52,46 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, ip, user, password }: CreateServerData = body;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!name || !ip || !user || !password) {
|
if (!name || !ip || !user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Missing required fields' },
|
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate SSH port
|
||||||
|
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH port must be between 1 and 65535' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authentication based on auth_type
|
||||||
|
const authType = auth_type ?? 'password';
|
||||||
|
|
||||||
|
if (authType === 'password') {
|
||||||
|
if (!password?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password is required for password authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === 'key') {
|
||||||
|
if (!ssh_key?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH key is required for key authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Check if server exists
|
// Check if server exists
|
||||||
@@ -73,7 +103,19 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.updateServer(id, { name, ip, user, password });
|
const result = db.updateServer(id, {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: authType,
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22,
|
||||||
|
color,
|
||||||
|
key_generated: key_generated ?? 0,
|
||||||
|
ssh_key_path
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -124,6 +166,9 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete all installed scripts associated with this server
|
||||||
|
db.deleteInstalledScriptsByServer(id);
|
||||||
|
|
||||||
const result = db.deleteServer(id);
|
const result = db.deleteServer(id);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
32
src/app/api/servers/generate-keypair/route.ts
Normal file
32
src/app/api/servers/generate-keypair/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getSSHService } from '../../../../server/ssh-service';
|
||||||
|
import { getDatabase } from '../../../../server/database';
|
||||||
|
|
||||||
|
export async function POST(_request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sshService = getSSHService();
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get the next available server ID for key file naming
|
||||||
|
const serverId = db.getNextServerId();
|
||||||
|
|
||||||
|
const keyPair = await sshService.generateKeyPair(serverId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
privateKey: keyPair.privateKey,
|
||||||
|
publicKey: keyPair.publicKey,
|
||||||
|
serverId: serverId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating SSH key pair:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to generate SSH key pair'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,18 +20,60 @@ export async function GET() {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, ip, user, password }: CreateServerData = body;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!name || !ip || !user || !password) {
|
if (!name || !ip || !user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Missing required fields' },
|
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate SSH port
|
||||||
|
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH port must be between 1 and 65535' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authentication based on auth_type
|
||||||
|
const authType = auth_type ?? 'password';
|
||||||
|
|
||||||
|
if (authType === 'password') {
|
||||||
|
if (!password?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password is required for password authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === 'key') {
|
||||||
|
if (!ssh_key?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH key is required for key authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const result = db.createServer({ name, ip, user, password });
|
const result = db.createServer({
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: authType,
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22,
|
||||||
|
color,
|
||||||
|
key_generated: key_generated ?? 0,
|
||||||
|
ssh_key_path
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
117
src/app/api/settings/auth-credentials/route.ts
Normal file
117
src/app/api/settings/auth-credentials/route.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
username: authConfig.username,
|
||||||
|
enabled: authConfig.enabled,
|
||||||
|
hasCredentials: authConfig.hasCredentials,
|
||||||
|
setupCompleted: authConfig.setupCompleted,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading auth credentials:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read auth configuration' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password, enabled } = await request.json() as { username: string; password: string; enabled?: boolean };
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username must be at least 3 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password must be at least 6 characters long' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAuthCredentials(username, password, enabled ?? false);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Authentication credentials updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating auth credentials:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update auth credentials' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { enabled } = await request.json() as { enabled: boolean };
|
||||||
|
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Enabled flag must be a boolean' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// When enabling, just update the flag
|
||||||
|
updateAuthEnabled(enabled);
|
||||||
|
} else {
|
||||||
|
// When disabling, clear all credentials and set flag to false
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH
|
||||||
|
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, '');
|
||||||
|
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, '');
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty lines
|
||||||
|
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||||
|
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating auth enabled status:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update auth status' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/settings/color-coding/route.ts
Normal file
75
src/app/api/settings/color-coding/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { enabled } = await request.json();
|
||||||
|
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Enabled must be a boolean value' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SERVER_COLOR_CODING_ENABLED already exists
|
||||||
|
const colorCodingRegex = /^SERVER_COLOR_CODING_ENABLED=.*$/m;
|
||||||
|
const colorCodingMatch = colorCodingRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (colorCodingMatch) {
|
||||||
|
// Replace existing SERVER_COLOR_CODING_ENABLED
|
||||||
|
envContent = envContent.replace(colorCodingRegex, `SERVER_COLOR_CODING_ENABLED=${enabled}`);
|
||||||
|
} else {
|
||||||
|
// Add new SERVER_COLOR_CODING_ENABLED
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `SERVER_COLOR_CODING_ENABLED=${enabled}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Color coding setting saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving color coding setting:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save color coding setting' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ enabled: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
|
||||||
|
// Extract SERVER_COLOR_CODING_ENABLED
|
||||||
|
const colorCodingRegex = /^SERVER_COLOR_CODING_ENABLED=(.*)$/m;
|
||||||
|
const colorCodingMatch = colorCodingRegex.exec(envContent);
|
||||||
|
const enabled = colorCodingMatch ? colorCodingMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||||
|
|
||||||
|
return NextResponse.json({ enabled });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading color coding setting:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read color coding setting' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/app/api/settings/filters/route.ts
Normal file
148
src/app/api/settings/filters/route.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { filters } = await request.json();
|
||||||
|
|
||||||
|
if (!filters || typeof filters !== 'object') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Filters object is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate filter structure
|
||||||
|
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!(field in filters)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Missing required field: ${field}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize filters to JSON string
|
||||||
|
const filtersJson = JSON.stringify(filters);
|
||||||
|
|
||||||
|
// Check if FILTERS already exists
|
||||||
|
const filtersRegex = /^FILTERS=.*$/m;
|
||||||
|
const filtersMatch = filtersRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (filtersMatch) {
|
||||||
|
// Replace existing FILTERS
|
||||||
|
envContent = envContent.replace(filtersRegex, `FILTERS=${filtersJson}`);
|
||||||
|
} else {
|
||||||
|
// Add new FILTERS
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `FILTERS=${filtersJson}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Filters saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving filters:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save filters' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ filters: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read .env file and extract FILTERS
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
const filtersRegex = /^FILTERS=(.*)$/m;
|
||||||
|
const filtersMatch = filtersRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (!filtersMatch) {
|
||||||
|
return NextResponse.json({ filters: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filtersJson = filtersMatch[1]?.trim();
|
||||||
|
|
||||||
|
// Check if filters JSON is empty or invalid
|
||||||
|
if (!filtersJson || filtersJson === '') {
|
||||||
|
return NextResponse.json({ filters: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = JSON.parse(filtersJson);
|
||||||
|
|
||||||
|
// Validate the parsed filters
|
||||||
|
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
|
||||||
|
const isValid = requiredFields.every(field => field in filters);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json({ filters: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ filters });
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Error parsing saved filters:', parseError);
|
||||||
|
return NextResponse.json({ filters: null });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading filters:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read filters' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ success: true, message: 'No filters to clear' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
|
||||||
|
// Remove FILTERS line
|
||||||
|
const filtersRegex = /^FILTERS=.*$/m;
|
||||||
|
const filtersMatch = filtersRegex.exec(envContent);
|
||||||
|
if (filtersMatch) {
|
||||||
|
envContent = envContent.replace(filtersRegex, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up extra newlines
|
||||||
|
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Filters cleared successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing filters:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to clear filters' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/settings/github-token/route.ts
Normal file
75
src/app/api/settings/github-token/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { token } = await request.json();
|
||||||
|
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Token is required and must be a string' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if GITHUB_TOKEN already exists
|
||||||
|
const githubTokenRegex = /^GITHUB_TOKEN=.*$/m;
|
||||||
|
const githubTokenMatch = githubTokenRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (githubTokenMatch) {
|
||||||
|
// Replace existing GITHUB_TOKEN
|
||||||
|
envContent = envContent.replace(githubTokenRegex, `GITHUB_TOKEN=${token}`);
|
||||||
|
} else {
|
||||||
|
// Add new GITHUB_TOKEN
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `GITHUB_TOKEN=${token}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'GitHub token saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving GitHub token:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save GitHub token' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ token: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read .env file and extract GITHUB_TOKEN
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
const githubTokenRegex = /^GITHUB_TOKEN=(.*)$/m;
|
||||||
|
const githubTokenMatch = githubTokenRegex.exec(envContent);
|
||||||
|
|
||||||
|
const token = githubTokenMatch ? githubTokenMatch[1] : null;
|
||||||
|
|
||||||
|
return NextResponse.json({ token });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading GitHub token:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read GitHub token' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/settings/save-filter/route.ts
Normal file
75
src/app/api/settings/save-filter/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { enabled } = await request.json();
|
||||||
|
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Enabled value must be a boolean' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SAVE_FILTER already exists
|
||||||
|
const saveFilterRegex = /^SAVE_FILTER=.*$/m;
|
||||||
|
const saveFilterMatch = saveFilterRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (saveFilterMatch) {
|
||||||
|
// Replace existing SAVE_FILTER
|
||||||
|
envContent = envContent.replace(saveFilterRegex, `SAVE_FILTER=${enabled}`);
|
||||||
|
} else {
|
||||||
|
// Add new SAVE_FILTER
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `SAVE_FILTER=${enabled}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Save filter setting saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving save filter setting:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save save filter setting' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ enabled: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read .env file and extract SAVE_FILTER
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
const saveFilterRegex = /^SAVE_FILTER=(.*)$/m;
|
||||||
|
const saveFilterMatch = saveFilterRegex.exec(envContent);
|
||||||
|
|
||||||
|
const enabled = saveFilterMatch ? saveFilterMatch[1] === 'true' : false;
|
||||||
|
|
||||||
|
return NextResponse.json({ enabled });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading save filter setting:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read save filter setting' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/app/api/settings/view-mode/route.ts
Normal file
81
src/app/api/settings/view-mode/route.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { viewMode } = await request.json();
|
||||||
|
|
||||||
|
if (!viewMode || !['card', 'list'].includes(viewMode as string)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'View mode must be either "card" or "list"' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if VIEW_MODE already exists
|
||||||
|
const viewModeRegex = /^VIEW_MODE=.*$/m;
|
||||||
|
const viewModeMatch = viewModeRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (viewModeMatch) {
|
||||||
|
// Replace existing VIEW_MODE
|
||||||
|
envContent = envContent.replace(viewModeRegex, `VIEW_MODE=${viewMode}`);
|
||||||
|
} else {
|
||||||
|
// Add new VIEW_MODE
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `VIEW_MODE=${viewMode}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'View mode saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving view mode:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save view mode' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read .env file and extract VIEW_MODE
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
const viewModeRegex = /^VIEW_MODE=(.*)$/m;
|
||||||
|
const viewModeMatch = viewModeRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (!viewModeMatch) {
|
||||||
|
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewMode = viewModeMatch[1]?.trim();
|
||||||
|
|
||||||
|
// Validate the view mode
|
||||||
|
if (!viewMode || !['card', 'list'].includes(viewMode)) {
|
||||||
|
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ viewMode });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading view mode:', error);
|
||||||
|
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
import { type Metadata } from "next";
|
import { type Metadata, type Viewport } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
|
|
||||||
import { TRPCReactProvider } from "~/trpc/react";
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
|
import { AuthProvider } from "./_components/AuthProvider";
|
||||||
|
import { AuthGuard } from "./_components/AuthGuard";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "PVE Scripts local",
|
title: "PVE Scripts local",
|
||||||
description: "",
|
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
|
||||||
icons: [
|
icons: [
|
||||||
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
||||||
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
||||||
@@ -15,6 +17,12 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
const geist = Geist({
|
const geist = Geist({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-jetbrains-mono",
|
variable: "--font-jetbrains-mono",
|
||||||
@@ -39,7 +47,13 @@ export default function RootLayout({
|
|||||||
className="bg-background text-foreground transition-colors"
|
className="bg-background text-foreground transition-colors"
|
||||||
suppressHydrationWarning={true}
|
suppressHydrationWarning={true}
|
||||||
>
|
>
|
||||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
<TRPCReactProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthGuard>
|
||||||
|
{children}
|
||||||
|
</AuthGuard>
|
||||||
|
</AuthProvider>
|
||||||
|
</TRPCReactProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
210
src/app/page.tsx
210
src/app/page.tsx
@@ -1,23 +1,140 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||||
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
||||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||||
import { ResyncButton } from './_components/ResyncButton';
|
import { ResyncButton } from './_components/ResyncButton';
|
||||||
import { Terminal } from './_components/Terminal';
|
import { Terminal } from './_components/Terminal';
|
||||||
|
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||||
import { SettingsButton } from './_components/SettingsButton';
|
import { SettingsButton } from './_components/SettingsButton';
|
||||||
|
import { HelpButton } from './_components/HelpButton';
|
||||||
import { VersionDisplay } from './_components/VersionDisplay';
|
import { VersionDisplay } from './_components/VersionDisplay';
|
||||||
import { Button } from './_components/ui/button';
|
import { Button } from './_components/ui/button';
|
||||||
|
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||||
|
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||||
|
import { Footer } from './_components/Footer';
|
||||||
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
|
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
|
||||||
|
return savedTab || 'scripts';
|
||||||
|
}
|
||||||
|
return 'scripts';
|
||||||
|
});
|
||||||
|
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
|
||||||
|
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Fetch data for script counts
|
||||||
|
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
|
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||||
|
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||||
|
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||||
|
|
||||||
|
// Save active tab to localStorage whenever it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('activeTab', activeTab);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
// Auto-show release notes modal after update
|
||||||
|
useEffect(() => {
|
||||||
|
if (versionData?.success && versionData.version) {
|
||||||
|
const currentVersion = versionData.version;
|
||||||
|
const lastSeenVersion = getLastSeenVersion();
|
||||||
|
|
||||||
|
// If we have a current version and either no last seen version or versions don't match
|
||||||
|
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
|
||||||
|
setHighlightVersion(currentVersion);
|
||||||
|
setReleaseNotesOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [versionData]);
|
||||||
|
|
||||||
|
const handleOpenReleaseNotes = () => {
|
||||||
|
setHighlightVersion(undefined);
|
||||||
|
setReleaseNotesOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseReleaseNotes = () => {
|
||||||
|
setReleaseNotesOpen(false);
|
||||||
|
setHighlightVersion(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate script counts
|
||||||
|
const scriptCounts = {
|
||||||
|
available: (() => {
|
||||||
|
if (!scriptCardsData?.success) return 0;
|
||||||
|
|
||||||
|
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
|
||||||
|
const scriptMap = new Map<string, any>();
|
||||||
|
|
||||||
|
scriptCardsData.cards?.forEach(script => {
|
||||||
|
if (script?.name && script?.slug) {
|
||||||
|
// Use slug as unique identifier, only keep first occurrence
|
||||||
|
if (!scriptMap.has(script.slug)) {
|
||||||
|
scriptMap.set(script.slug, script);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return scriptMap.size;
|
||||||
|
})(),
|
||||||
|
downloaded: (() => {
|
||||||
|
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
||||||
|
|
||||||
|
// First deduplicate GitHub scripts using Map by slug
|
||||||
|
const scriptMap = new Map<string, any>();
|
||||||
|
|
||||||
|
scriptCardsData.cards?.forEach(script => {
|
||||||
|
if (script?.name && script?.slug) {
|
||||||
|
if (!scriptMap.has(script.slug)) {
|
||||||
|
scriptMap.set(script.slug, script);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deduplicatedGithubScripts = Array.from(scriptMap.values());
|
||||||
|
const localScripts = localScriptsData.scripts ?? [];
|
||||||
|
|
||||||
|
// Count scripts that are both in deduplicated GitHub data and have local versions
|
||||||
|
return deduplicatedGithubScripts.filter(script => {
|
||||||
|
if (!script?.name) return false;
|
||||||
|
return localScripts.some(local => {
|
||||||
|
if (!local?.name) return false;
|
||||||
|
const localName = local.name.replace(/\.sh$/, '');
|
||||||
|
return localName.toLowerCase() === script.name.toLowerCase() ||
|
||||||
|
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
|
||||||
|
});
|
||||||
|
}).length;
|
||||||
|
})(),
|
||||||
|
installed: installedScriptsData?.scripts?.length ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToTerminal = () => {
|
||||||
|
if (terminalRef.current) {
|
||||||
|
// Get the element's position and scroll with a small offset for better mobile experience
|
||||||
|
const elementTop = terminalRef.current.offsetTop;
|
||||||
|
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: elementTop - offset,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
|
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
|
||||||
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
|
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
|
||||||
|
// Scroll to terminal after a short delay to ensure it's rendered
|
||||||
|
setTimeout(scrollToTerminal, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseTerminal = () => {
|
const handleCloseTerminal = () => {
|
||||||
@@ -26,72 +143,85 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background">
|
<main className="min-h-screen bg-background">
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-6 sm:mb-8">
|
||||||
<h1 className="text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-3">
|
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-2 sm:gap-3">
|
||||||
<Rocket className="h-9 w-9" />
|
|
||||||
PVE Scripts Management
|
<span className="break-words">PVE Scripts Management</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
|
||||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center px-2">
|
||||||
<VersionDisplay />
|
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 p-6 bg-card rounded-lg shadow-sm border border-border">
|
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
<ServerSettingsButton />
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</div>
|
<ResyncButton />
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
<HelpButton />
|
||||||
<ResyncButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<div className="border-b border-border">
|
<div className="border-b border-border">
|
||||||
<nav className="-mb-px flex space-x-8">
|
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="null"
|
size="null"
|
||||||
onClick={() => setActiveTab('scripts')}
|
onClick={() => setActiveTab('scripts')}
|
||||||
className={`px-3 py-1 text-sm flex items-center gap-2 ${
|
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||||
activeTab === 'scripts'
|
activeTab === 'scripts'
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||||
: 'hover:bg-accent hover:text-accent-foreground'
|
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||||
}`}>
|
}`}>
|
||||||
<Package className="h-4 w-4" />
|
<Package className="h-4 w-4" />
|
||||||
Available Scripts
|
<span className="hidden sm:inline">Available Scripts</span>
|
||||||
|
<span className="sm:hidden">Available</span>
|
||||||
|
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||||
|
{scriptCounts.available}
|
||||||
|
</span>
|
||||||
|
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="null"
|
size="null"
|
||||||
onClick={() => setActiveTab('downloaded')}
|
onClick={() => setActiveTab('downloaded')}
|
||||||
className={`px-3 py-1 text-sm flex items-center gap-2 ${
|
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||||
activeTab === 'downloaded'
|
activeTab === 'downloaded'
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||||
: 'hover:bg-accent hover:text-accent-foreground'
|
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||||
}`}>
|
}`}>
|
||||||
<HardDrive className="h-4 w-4" />
|
<HardDrive className="h-4 w-4" />
|
||||||
Downloaded Scripts
|
<span className="hidden sm:inline">Downloaded Scripts</span>
|
||||||
|
<span className="sm:hidden">Downloaded</span>
|
||||||
|
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||||
|
{scriptCounts.downloaded}
|
||||||
|
</span>
|
||||||
|
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="null"
|
size="null"
|
||||||
onClick={() => setActiveTab('installed')}
|
onClick={() => setActiveTab('installed')}
|
||||||
className={`px-3 py-1 text-sm flex items-center gap-2 ${
|
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||||
activeTab === 'installed'
|
activeTab === 'installed'
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||||
: 'hover:bg-accent hover:text-accent-foreground'
|
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||||
}`}>
|
}`}>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
Installed Scripts
|
<span className="hidden sm:inline">Installed Scripts</span>
|
||||||
|
<span className="sm:hidden">Installed</span>
|
||||||
|
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||||
|
{scriptCounts.installed}
|
||||||
|
</span>
|
||||||
|
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +231,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Running Script Terminal */}
|
{/* Running Script Terminal */}
|
||||||
{runningScript && (
|
{runningScript && (
|
||||||
<div className="mb-8">
|
<div ref={terminalRef} className="mb-8">
|
||||||
<Terminal
|
<Terminal
|
||||||
scriptPath={runningScript.path}
|
scriptPath={runningScript.path}
|
||||||
onClose={handleCloseTerminal}
|
onClose={handleCloseTerminal}
|
||||||
@@ -117,13 +247,23 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'downloaded' && (
|
{activeTab === 'downloaded' && (
|
||||||
<DownloadedScriptsTab />
|
<DownloadedScriptsTab onInstallScript={handleRunScript} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'installed' && (
|
{activeTab === 'installed' && (
|
||||||
<InstalledScriptsTab />
|
<InstalledScriptsTab />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Footer onOpenReleaseNotes={handleOpenReleaseNotes} />
|
||||||
|
|
||||||
|
{/* Release Notes Modal */}
|
||||||
|
<ReleaseNotesModal
|
||||||
|
isOpen={releaseNotesOpen}
|
||||||
|
onClose={handleCloseReleaseNotes}
|
||||||
|
highlightVersion={highlightVersion}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/env.js
20
src/env.js
@@ -23,6 +23,16 @@ export const env = createEnv({
|
|||||||
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
|
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
|
||||||
// WebSocket Configuration
|
// WebSocket Configuration
|
||||||
WEBSOCKET_PORT: z.string().default("3001"),
|
WEBSOCKET_PORT: z.string().default("3001"),
|
||||||
|
// GitHub Configuration
|
||||||
|
GITHUB_TOKEN: z.string().optional(),
|
||||||
|
// Authentication Configuration
|
||||||
|
AUTH_USERNAME: z.string().optional(),
|
||||||
|
AUTH_PASSWORD_HASH: z.string().optional(),
|
||||||
|
AUTH_ENABLED: z.string().optional(),
|
||||||
|
AUTH_SETUP_COMPLETED: z.string().optional(),
|
||||||
|
JWT_SECRET: z.string().optional(),
|
||||||
|
// Server Color Coding Configuration
|
||||||
|
SERVER_COLOR_CODING_ENABLED: z.string().optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,6 +62,16 @@ export const env = createEnv({
|
|||||||
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
|
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
|
||||||
// WebSocket Configuration
|
// WebSocket Configuration
|
||||||
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
|
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
|
||||||
|
// GitHub Configuration
|
||||||
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||||
|
// Authentication Configuration
|
||||||
|
AUTH_USERNAME: process.env.AUTH_USERNAME,
|
||||||
|
AUTH_PASSWORD_HASH: process.env.AUTH_PASSWORD_HASH,
|
||||||
|
AUTH_ENABLED: process.env.AUTH_ENABLED,
|
||||||
|
AUTH_SETUP_COMPLETED: process.env.AUTH_SETUP_COMPLETED,
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
// Server Color Coding Configuration
|
||||||
|
SERVER_COLOR_CODING_ENABLED: process.env.SERVER_COLOR_CODING_ENABLED,
|
||||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|||||||
240
src/lib/auth.ts
Normal file
240
src/lib/auth.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10;
|
||||||
|
const JWT_EXPIRY = '7d'; // 7 days
|
||||||
|
|
||||||
|
// Cache for JWT secret to avoid multiple file reads
|
||||||
|
let jwtSecretCache: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or generate JWT secret
|
||||||
|
*/
|
||||||
|
export function getJwtSecret(): string {
|
||||||
|
// Return cached secret if available
|
||||||
|
if (jwtSecretCache) {
|
||||||
|
return jwtSecretCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if JWT_SECRET already exists
|
||||||
|
const jwtSecretRegex = /^JWT_SECRET=(.*)$/m;
|
||||||
|
const jwtSecretMatch = jwtSecretRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (jwtSecretMatch?.[1]?.trim()) {
|
||||||
|
jwtSecretCache = jwtSecretMatch[1].trim();
|
||||||
|
return jwtSecretCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new secret
|
||||||
|
const newSecret = randomBytes(64).toString('hex');
|
||||||
|
|
||||||
|
// Add to .env file
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `JWT_SECRET=${newSecret}\n`;
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
// Cache the new secret
|
||||||
|
jwtSecretCache = newSecret;
|
||||||
|
|
||||||
|
return newSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a password using bcrypt
|
||||||
|
*/
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare a password with a hash
|
||||||
|
*/
|
||||||
|
export async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token
|
||||||
|
*/
|
||||||
|
export function generateToken(username: string): string {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a JWT token
|
||||||
|
*/
|
||||||
|
export function verifyToken(token: string): { username: string } | null {
|
||||||
|
try {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
const decoded = jwt.verify(token, secret) as { username: string };
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read auth configuration from .env
|
||||||
|
*/
|
||||||
|
export function getAuthConfig(): {
|
||||||
|
username: string | null;
|
||||||
|
passwordHash: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
hasCredentials: boolean;
|
||||||
|
setupCompleted: boolean;
|
||||||
|
} {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return {
|
||||||
|
username: null,
|
||||||
|
passwordHash: null,
|
||||||
|
enabled: false,
|
||||||
|
hasCredentials: false,
|
||||||
|
setupCompleted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
|
||||||
|
// Extract AUTH_USERNAME
|
||||||
|
const usernameRegex = /^AUTH_USERNAME=(.*)$/m;
|
||||||
|
const usernameMatch = usernameRegex.exec(envContent);
|
||||||
|
const username = usernameMatch ? usernameMatch[1]?.trim() : null;
|
||||||
|
|
||||||
|
// Extract AUTH_PASSWORD_HASH
|
||||||
|
const passwordHashRegex = /^AUTH_PASSWORD_HASH=(.*)$/m;
|
||||||
|
const passwordHashMatch = passwordHashRegex.exec(envContent);
|
||||||
|
const passwordHash = passwordHashMatch ? passwordHashMatch[1]?.trim() : null;
|
||||||
|
|
||||||
|
// Extract AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=(.*)$/m;
|
||||||
|
const enabledMatch = enabledRegex.exec(envContent);
|
||||||
|
const enabled = enabledMatch ? enabledMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||||
|
|
||||||
|
// Extract AUTH_SETUP_COMPLETED
|
||||||
|
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=(.*)$/m;
|
||||||
|
const setupCompletedMatch = setupCompletedRegex.exec(envContent);
|
||||||
|
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||||
|
|
||||||
|
const hasCredentials = !!(username && passwordHash);
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: username ?? null,
|
||||||
|
passwordHash: passwordHash ?? null,
|
||||||
|
enabled,
|
||||||
|
hasCredentials,
|
||||||
|
setupCompleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update auth credentials in .env
|
||||||
|
*/
|
||||||
|
export async function updateAuthCredentials(
|
||||||
|
username: string,
|
||||||
|
password?: string,
|
||||||
|
enabled?: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the password if provided
|
||||||
|
const passwordHash = password ? await hashPassword(password) : null;
|
||||||
|
|
||||||
|
// Update or add AUTH_USERNAME
|
||||||
|
const usernameRegex = /^AUTH_USERNAME=.*$/m;
|
||||||
|
if (usernameRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(usernameRegex, `AUTH_USERNAME=${username}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_USERNAME=${username}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_PASSWORD_HASH only if password is provided
|
||||||
|
if (passwordHash) {
|
||||||
|
const passwordHashRegex = /^AUTH_PASSWORD_HASH=.*$/m;
|
||||||
|
if (passwordHashRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(passwordHashRegex, `AUTH_PASSWORD_HASH=${passwordHash}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_PASSWORD_HASH=${passwordHash}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED if provided
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set AUTH_SETUP_COMPLETED flag in .env
|
||||||
|
*/
|
||||||
|
export function setSetupCompleted(): void {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_SETUP_COMPLETED
|
||||||
|
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
|
||||||
|
if (setupCompletedRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update AUTH_ENABLED flag in .env
|
||||||
|
*/
|
||||||
|
export function updateAuthEnabled(enabled: boolean): void {
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or add AUTH_ENABLED
|
||||||
|
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||||
|
if (enabledRegex.test(envContent)) {
|
||||||
|
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
|
||||||
|
} else {
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
}
|
||||||
|
|
||||||
35
src/lib/colorUtils.ts
Normal file
35
src/lib/colorUtils.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Calculate the appropriate text color (black or white) for a given background color
|
||||||
|
* to ensure optimal readability based on luminance
|
||||||
|
*/
|
||||||
|
export function getContrastColor(hexColor: string): 'black' | 'white' {
|
||||||
|
if (!hexColor?.length || hexColor.length !== 7 || !hexColor.startsWith('#')) {
|
||||||
|
return 'black'; // Default to black for invalid colors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the # and convert to RGB
|
||||||
|
const r = parseInt(hexColor.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hexColor.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hexColor.slice(5, 7), 16);
|
||||||
|
|
||||||
|
// Calculate relative luminance using the standard formula
|
||||||
|
// https://www.w3.org/WAI/GL/wiki/Relative_luminance
|
||||||
|
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||||
|
|
||||||
|
// Return black for light backgrounds, white for dark backgrounds
|
||||||
|
return luminance > 0.5 ? 'black' : 'white';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a color string is a valid hex color
|
||||||
|
*/
|
||||||
|
export function isValidHexColor(color: string): boolean {
|
||||||
|
return /^#[0-9A-F]{6}$/i.test(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a default color for servers that don't have one set
|
||||||
|
*/
|
||||||
|
export function getDefaultServerColor(): string {
|
||||||
|
return '#3b82f6'; // Blue-500 from Tailwind
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
import { getDatabase } from "~/server/database";
|
import { getDatabase } from "~/server/database";
|
||||||
|
// Removed unused imports
|
||||||
|
|
||||||
|
|
||||||
export const installedScriptsRouter = createTRPCRouter({
|
export const installedScriptsRouter = createTRPCRouter({
|
||||||
// Get all installed scripts
|
// Get all installed scripts
|
||||||
@@ -81,7 +83,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
server_id: z.number().optional(),
|
server_id: z.number().optional(),
|
||||||
execution_mode: z.enum(['local', 'ssh']),
|
execution_mode: z.enum(['local', 'ssh']),
|
||||||
status: z.enum(['in_progress', 'success', 'failed']),
|
status: z.enum(['in_progress', 'success', 'failed']),
|
||||||
output_log: z.string().optional()
|
output_log: z.string().optional(),
|
||||||
|
web_ui_ip: z.string().optional(),
|
||||||
|
web_ui_port: z.number().optional()
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
@@ -108,7 +112,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
script_name: z.string().optional(),
|
script_name: z.string().optional(),
|
||||||
container_id: z.string().optional(),
|
container_id: z.string().optional(),
|
||||||
status: z.enum(['in_progress', 'success', 'failed']).optional(),
|
status: z.enum(['in_progress', 'success', 'failed']).optional(),
|
||||||
output_log: z.string().optional()
|
output_log: z.string().optional(),
|
||||||
|
web_ui_ip: z.string().optional(),
|
||||||
|
web_ui_port: z.number().optional()
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
@@ -203,5 +209,946 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
stats: null
|
stats: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Auto-detect LXC containers with community-script tag
|
||||||
|
autoDetectLXCContainers: publicProcedure
|
||||||
|
.input(z.object({ serverId: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const server = db.getServerById(input.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
console.error('Server not found for ID:', input.serverId);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found',
|
||||||
|
detectedContainers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
// Test SSH connection first
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
|
||||||
|
detectedContainers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Use the working approach - manual loop through all config files
|
||||||
|
const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`;
|
||||||
|
let detectedContainers: any[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
let commandOutput = '';
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
command,
|
||||||
|
(data: string) => {
|
||||||
|
commandOutput += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
console.error('Command error:', error);
|
||||||
|
},
|
||||||
|
(_exitCode: number) => {
|
||||||
|
|
||||||
|
// Parse the complete output to get config file paths that contain community-script tag
|
||||||
|
const configFiles = commandOutput.split('\n')
|
||||||
|
.filter((line: string) => line.trim())
|
||||||
|
.map((line: string) => line.trim())
|
||||||
|
.filter((line: string) => line.endsWith('.conf'));
|
||||||
|
|
||||||
|
|
||||||
|
// Process each config file to extract hostname
|
||||||
|
const processPromises = configFiles.map(async (configPath: string) => {
|
||||||
|
try {
|
||||||
|
const containerId = configPath.split('/').pop()?.replace('.conf', '');
|
||||||
|
if (!containerId) return null;
|
||||||
|
|
||||||
|
|
||||||
|
// Read the config file content
|
||||||
|
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return new Promise<any>((readResolve) => {
|
||||||
|
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
readCommand,
|
||||||
|
(configData: string) => {
|
||||||
|
// Parse config file for hostname
|
||||||
|
const lines = configData.split('\n');
|
||||||
|
let hostname = '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.startsWith('hostname:')) {
|
||||||
|
hostname = trimmedLine.substring(9).trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname) {
|
||||||
|
const container = {
|
||||||
|
containerId,
|
||||||
|
hostname,
|
||||||
|
configPath,
|
||||||
|
serverId: Number((server as any).id),
|
||||||
|
serverName: (server as any).name
|
||||||
|
};
|
||||||
|
readResolve(container);
|
||||||
|
} else {
|
||||||
|
readResolve(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(readError: string) => {
|
||||||
|
console.error(`Error reading config file ${configPath}:`, readError);
|
||||||
|
readResolve(null);
|
||||||
|
},
|
||||||
|
(_exitCode: number) => {
|
||||||
|
readResolve(null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing config file ${configPath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all config files to be processed
|
||||||
|
void Promise.all(processPromises).then((results) => {
|
||||||
|
detectedContainers = results.filter(result => result !== null);
|
||||||
|
resolve();
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error processing config files:', error);
|
||||||
|
reject(new Error(`Error processing config files: ${error}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Get existing scripts to check for duplicates
|
||||||
|
const existingScripts = db.getAllInstalledScripts();
|
||||||
|
|
||||||
|
// Create installed script records for detected containers (skip duplicates)
|
||||||
|
const createdScripts = [];
|
||||||
|
const skippedScripts = [];
|
||||||
|
|
||||||
|
for (const container of detectedContainers) {
|
||||||
|
try {
|
||||||
|
// Check if a script with this container_id and server_id already exists
|
||||||
|
const duplicate = existingScripts.find((script: any) =>
|
||||||
|
script.container_id === container.containerId &&
|
||||||
|
script.server_id === container.serverId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
skippedScripts.push({
|
||||||
|
containerId: container.containerId,
|
||||||
|
hostname: container.hostname,
|
||||||
|
serverName: container.serverName
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.createInstalledScript({
|
||||||
|
script_name: container.hostname,
|
||||||
|
script_path: `detected/${container.hostname}`,
|
||||||
|
container_id: container.containerId,
|
||||||
|
server_id: container.serverId,
|
||||||
|
execution_mode: 'ssh',
|
||||||
|
status: 'success',
|
||||||
|
output_log: `Auto-detected from LXC config: ${container.configPath}`
|
||||||
|
});
|
||||||
|
|
||||||
|
createdScripts.push({
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
containerId: container.containerId,
|
||||||
|
hostname: container.hostname,
|
||||||
|
serverName: container.serverName
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error creating script record for ${container.hostname}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = skippedScripts.length > 0
|
||||||
|
? `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
|
||||||
|
: `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts.`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: message,
|
||||||
|
detectedContainers: createdScripts,
|
||||||
|
skippedContainers: skippedScripts
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in autoDetectLXCContainers:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to auto-detect LXC containers',
|
||||||
|
detectedContainers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Cleanup orphaned scripts (check if LXC containers still exist on servers)
|
||||||
|
cleanupOrphanedScripts: publicProcedure
|
||||||
|
.mutation(async () => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const allScripts = db.getAllInstalledScripts();
|
||||||
|
const allServers = db.getAllServers();
|
||||||
|
|
||||||
|
|
||||||
|
if (allScripts.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'No scripts to check',
|
||||||
|
deletedCount: 0,
|
||||||
|
deletedScripts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
const deletedScripts: string[] = [];
|
||||||
|
const scriptsToCheck = allScripts.filter((script: any) =>
|
||||||
|
script.execution_mode === 'ssh' &&
|
||||||
|
script.server_id &&
|
||||||
|
script.container_id
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
for (const script of scriptsToCheck) {
|
||||||
|
try {
|
||||||
|
const scriptData = script as any;
|
||||||
|
const server = allServers.find((s: any) => s.id === scriptData.server_id);
|
||||||
|
if (!server) {
|
||||||
|
db.deleteInstalledScript(Number(scriptData.id));
|
||||||
|
deletedScripts.push(String(scriptData.script_name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Test SSH connection
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the container config file still exists
|
||||||
|
const checkCommand = `test -f "/etc/pve/lxc/${scriptData.container_id}.conf" && echo "exists" || echo "not_found"`;
|
||||||
|
|
||||||
|
const containerExists = await new Promise<boolean>((resolve) => {
|
||||||
|
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
checkCommand,
|
||||||
|
(data: string) => {
|
||||||
|
resolve(data.trim() === 'exists');
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
console.error(`Error checking container ${scriptData.script_name}:`, error);
|
||||||
|
resolve(false);
|
||||||
|
},
|
||||||
|
(_exitCode: number) => {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!containerExists) {
|
||||||
|
db.deleteInstalledScript(Number(scriptData.id));
|
||||||
|
deletedScripts.push(String(scriptData.script_name));
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking script ${(script as any).script_name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Cleanup completed. ${deletedScripts.length} orphaned script(s) removed.`,
|
||||||
|
deletedCount: deletedScripts.length,
|
||||||
|
deletedScripts: deletedScripts
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in cleanupOrphanedScripts:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to cleanup orphaned scripts',
|
||||||
|
deletedCount: 0,
|
||||||
|
deletedScripts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get container running statuses
|
||||||
|
getContainerStatuses: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
serverIds: z.array(z.number()).optional() // Optional: check specific servers, or all if empty
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const allServers = db.getAllServers();
|
||||||
|
const statusMap: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
// Determine which servers to check
|
||||||
|
const serversToCheck = input.serverIds
|
||||||
|
? allServers.filter((s: any) => input.serverIds!.includes(Number(s.id)))
|
||||||
|
: allServers;
|
||||||
|
|
||||||
|
|
||||||
|
// Check status for each server
|
||||||
|
for (const server of serversToCheck) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Test SSH connection
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run pct list to get all container statuses at once
|
||||||
|
const listCommand = 'pct list';
|
||||||
|
let listOutput = '';
|
||||||
|
|
||||||
|
// Add timeout to prevent hanging connections
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('SSH command timeout after 30 seconds')), 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
listCommand,
|
||||||
|
(data: string) => {
|
||||||
|
listOutput += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
console.error(`pct list error on server ${(server as any).name}:`, error);
|
||||||
|
reject(new Error(error));
|
||||||
|
},
|
||||||
|
(_exitCode: number) => {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse pct list output
|
||||||
|
const lines = listOutput.split('\n').filter(line => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
// pct list format: CTID Status Name
|
||||||
|
// Example: "100 running my-container"
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const containerId = parts[0];
|
||||||
|
const status = parts[1];
|
||||||
|
|
||||||
|
if (containerId && status) {
|
||||||
|
// Map pct list status to our status
|
||||||
|
let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown';
|
||||||
|
if (status === 'running') {
|
||||||
|
mappedStatus = 'running';
|
||||||
|
} else if (status === 'stopped') {
|
||||||
|
mappedStatus = 'stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusMap[containerId] = mappedStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing server ${(server as any).name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
statusMap
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getContainerStatuses:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch container statuses',
|
||||||
|
statusMap: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get container status (running/stopped)
|
||||||
|
getContainerStatus: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const script = db.getInstalledScriptById(input.id);
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script not found',
|
||||||
|
status: 'unknown' as const
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptData = script as any;
|
||||||
|
|
||||||
|
// Only check status for SSH scripts with container_id
|
||||||
|
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script is not an SSH script with container ID',
|
||||||
|
status: 'unknown' as const
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get server info
|
||||||
|
const server = db.getServerById(Number(scriptData.server_id));
|
||||||
|
if (!server) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found',
|
||||||
|
status: 'unknown' as const
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
// Test SSH connection first
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
|
||||||
|
status: 'unknown' as const
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check container status
|
||||||
|
const statusCommand = `pct status ${scriptData.container_id}`;
|
||||||
|
let statusOutput = '';
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
statusCommand,
|
||||||
|
(data: string) => {
|
||||||
|
statusOutput += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
console.error('Status command error:', error);
|
||||||
|
reject(new Error(error));
|
||||||
|
},
|
||||||
|
(_exitCode: number) => {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse status from output
|
||||||
|
let status: 'running' | 'stopped' | 'unknown' = 'unknown';
|
||||||
|
if (statusOutput.includes('status: running')) {
|
||||||
|
status = 'running';
|
||||||
|
} else if (statusOutput.includes('status: stopped')) {
|
||||||
|
status = 'stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status,
|
||||||
|
error: undefined
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getContainerStatus:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to get container status',
|
||||||
|
status: 'unknown' as const
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Control container (start/stop)
|
||||||
|
controlContainer: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
action: z.enum(['start', 'stop'])
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const script = db.getInstalledScriptById(input.id);
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptData = script as any;
|
||||||
|
|
||||||
|
// Only control SSH scripts with container_id
|
||||||
|
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script is not an SSH script with container ID'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get server info
|
||||||
|
const server = db.getServerById(Number(scriptData.server_id));
|
||||||
|
if (!server) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
// Test SSH connection first
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute control command
|
||||||
|
const controlCommand = `pct ${input.action} ${scriptData.container_id}`;
|
||||||
|
let commandOutput = '';
|
||||||
|
let commandError = '';
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
controlCommand,
|
||||||
|
(data: string) => {
|
||||||
|
commandOutput += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
commandError += error;
|
||||||
|
},
|
||||||
|
(exitCode: number) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const errorMessage = commandError || commandOutput || `Command failed with exit code ${exitCode}`;
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Container ${scriptData.container_id} ${input.action} command executed successfully`,
|
||||||
|
containerId: scriptData.container_id
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in controlContainer:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to control container'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Destroy container and delete DB record
|
||||||
|
destroyContainer: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const script = db.getInstalledScriptById(input.id);
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptData = script as any;
|
||||||
|
|
||||||
|
// Only destroy SSH scripts with container_id
|
||||||
|
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script is not an SSH script with container ID'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get server info
|
||||||
|
const server = db.getServerById(Number(scriptData.server_id));
|
||||||
|
if (!server) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
// Test SSH connection first
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check if container is running and stop it if necessary
|
||||||
|
const statusCommand = `pct status ${scriptData.container_id}`;
|
||||||
|
let statusOutput = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
statusCommand,
|
||||||
|
(data: string) => {
|
||||||
|
statusOutput += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
reject(new Error(error));
|
||||||
|
},
|
||||||
|
(_exitCode: number) => {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if container is running
|
||||||
|
if (statusOutput.includes('status: running')) {
|
||||||
|
// Stop the container first
|
||||||
|
const stopCommand = `pct stop ${scriptData.container_id}`;
|
||||||
|
let stopOutput = '';
|
||||||
|
let stopError = '';
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
stopCommand,
|
||||||
|
(data: string) => {
|
||||||
|
stopOutput += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
stopError += error;
|
||||||
|
},
|
||||||
|
(exitCode: number) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const errorMessage = stopError || stopOutput || `Stop command failed with exit code ${exitCode}`;
|
||||||
|
reject(new Error(`Failed to stop container: ${errorMessage}`));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute destroy command
|
||||||
|
const destroyCommand = `pct destroy ${scriptData.container_id}`;
|
||||||
|
let commandOutput = '';
|
||||||
|
let commandError = '';
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
destroyCommand,
|
||||||
|
(data: string) => {
|
||||||
|
commandOutput += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
commandError += error;
|
||||||
|
},
|
||||||
|
(exitCode: number) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const errorMessage = commandError || commandOutput || `Destroy command failed with exit code ${exitCode}`;
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If destroy was successful, delete the database record
|
||||||
|
const deleteResult = db.deleteInstalledScript(input.id);
|
||||||
|
|
||||||
|
if (deleteResult.changes === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Container destroyed but failed to delete database record'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if container was stopped first
|
||||||
|
const wasStopped = statusOutput.includes('status: running');
|
||||||
|
const message = wasStopped
|
||||||
|
? `Container ${scriptData.container_id} stopped and destroyed successfully, database record deleted`
|
||||||
|
: `Container ${scriptData.container_id} destroyed successfully, database record deleted`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in destroyContainer:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to destroy container'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Auto-detect Web UI IP and port
|
||||||
|
autoDetectWebUI: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Auto-detect WebUI called with id:', input.id);
|
||||||
|
const db = getDatabase();
|
||||||
|
const script = db.getInstalledScriptById(input.id);
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
console.log('❌ Script not found for id:', input.id);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptData = script as any;
|
||||||
|
console.log('📋 Script data:', {
|
||||||
|
id: scriptData.id,
|
||||||
|
execution_mode: scriptData.execution_mode,
|
||||||
|
server_id: scriptData.server_id,
|
||||||
|
container_id: scriptData.container_id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only works for SSH mode scripts with container_id
|
||||||
|
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
|
||||||
|
console.log('❌ Validation failed - not SSH mode or missing server/container ID');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Auto-detect only works for SSH mode scripts with container ID'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get server info
|
||||||
|
const server = db.getServerById(Number(scriptData.server_id));
|
||||||
|
if (!server) {
|
||||||
|
console.log('❌ Server not found for id:', scriptData.server_id);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🖥️ Server found:', { id: (server as any).id, name: (server as any).name, ip: (server as any).ip });
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
// Test SSH connection first
|
||||||
|
console.log('🔌 Testing SSH connection...');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
console.log('❌ SSH connection failed:', (connectionTest as any).error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ SSH connection successful');
|
||||||
|
|
||||||
|
// Run hostname -I inside the container
|
||||||
|
// Use pct exec instead of pct enter -c (which doesn't exist)
|
||||||
|
const hostnameCommand = `pct exec ${scriptData.container_id} -- hostname -I`;
|
||||||
|
console.log('🚀 Running command:', hostnameCommand);
|
||||||
|
let commandOutput = '';
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
hostnameCommand,
|
||||||
|
(data: string) => {
|
||||||
|
console.log('📤 Command output chunk:', data);
|
||||||
|
commandOutput += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
console.log('❌ Command error:', error);
|
||||||
|
reject(new Error(error));
|
||||||
|
},
|
||||||
|
(exitCode: number) => {
|
||||||
|
console.log('🏁 Command finished with exit code:', exitCode);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
reject(new Error(`Command failed with exit code ${exitCode}`));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse output to get first IP address
|
||||||
|
console.log('📝 Full command output:', commandOutput);
|
||||||
|
const ips = commandOutput.trim().split(/\s+/);
|
||||||
|
const detectedIp = ips[0];
|
||||||
|
console.log('🔍 Parsed IPs:', ips);
|
||||||
|
console.log('🎯 Detected IP:', detectedIp);
|
||||||
|
|
||||||
|
if (!detectedIp || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(detectedIp)) {
|
||||||
|
console.log('❌ Invalid IP address detected:', detectedIp);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Could not detect valid IP address from container'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the script's interface_port from metadata (prioritize metadata over existing database values)
|
||||||
|
let detectedPort = 80; // Default fallback
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Import localScriptsService to get script metadata
|
||||||
|
const { localScriptsService } = await import('~/server/services/localScripts');
|
||||||
|
|
||||||
|
// Get all scripts and find the one matching our script name
|
||||||
|
const allScripts = await localScriptsService.getAllScripts();
|
||||||
|
|
||||||
|
// Extract script slug from script_name (remove .sh extension)
|
||||||
|
const scriptSlug = scriptData.script_name.replace(/\.sh$/, '');
|
||||||
|
console.log('🔍 Looking for script with slug:', scriptSlug);
|
||||||
|
|
||||||
|
const scriptMetadata = allScripts.find(script => script.slug === scriptSlug);
|
||||||
|
|
||||||
|
if (scriptMetadata?.interface_port) {
|
||||||
|
detectedPort = scriptMetadata.interface_port;
|
||||||
|
console.log('📋 Found interface_port in metadata:', detectedPort);
|
||||||
|
} else {
|
||||||
|
console.log('📋 No interface_port found in metadata, using default port 80');
|
||||||
|
detectedPort = 80; // Default to port 80 if no metadata port found
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error getting script metadata, using default port 80:', error);
|
||||||
|
detectedPort = 80; // Default to port 80 if metadata lookup fails
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎯 Final detected port:', detectedPort);
|
||||||
|
|
||||||
|
// Update the database with detected IP and port
|
||||||
|
console.log('💾 Updating database with IP:', detectedIp, 'Port:', detectedPort);
|
||||||
|
const updateResult = db.updateInstalledScript(input.id, {
|
||||||
|
web_ui_ip: detectedIp,
|
||||||
|
web_ui_port: detectedPort
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updateResult.changes === 0) {
|
||||||
|
console.log('❌ Database update failed - no changes made');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update database with detected IP'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Successfully updated database');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully detected IP: ${detectedIp}:${detectedPort} for LXC ${scriptData.container_id} on ${(server as any).name}`,
|
||||||
|
detectedIp,
|
||||||
|
detectedPort: detectedPort,
|
||||||
|
containerId: scriptData.container_id,
|
||||||
|
serverName: (server as any).name
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in autoDetectWebUI:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to auto-detect Web UI IP'
|
||||||
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Get all downloaded scripts from all directories
|
||||||
|
getAllDownloadedScripts: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
const scripts = await scriptManager.getAllDownloadedScripts();
|
||||||
|
return {
|
||||||
|
scripts,
|
||||||
|
directoryInfo: scriptManager.getScriptsDirectoryInfo()
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
|
||||||
// Get script content for viewing
|
// Get script content for viewing
|
||||||
getScriptContent: publicProcedure
|
getScriptContent: publicProcedure
|
||||||
@@ -163,12 +173,22 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
const script = scripts.find(s => s.slug === card.slug);
|
const script = scripts.find(s => s.slug === card.slug);
|
||||||
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
|
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
|
||||||
|
|
||||||
|
// Extract OS and version from first install method
|
||||||
|
const firstInstallMethod = script?.install_methods?.[0];
|
||||||
|
const os = firstInstallMethod?.resources?.os;
|
||||||
|
const version = firstInstallMethod?.resources?.version;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...card,
|
...card,
|
||||||
categories: script?.categories ?? [],
|
categories: script?.categories ?? [],
|
||||||
categoryNames: categoryNames,
|
categoryNames: categoryNames,
|
||||||
// Add date_created from script
|
// Add date_created from script
|
||||||
date_created: script?.date_created,
|
date_created: script?.date_created,
|
||||||
|
// Add OS and version from install methods
|
||||||
|
os: os,
|
||||||
|
version: version,
|
||||||
|
// Add interface port
|
||||||
|
interface_port: script?.interface_port,
|
||||||
} as ScriptCard;
|
} as ScriptCard;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,6 +254,58 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Load multiple scripts from GitHub
|
||||||
|
loadMultipleScripts: publicProcedure
|
||||||
|
.input(z.object({ slugs: z.array(z.string()) }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const successful = [];
|
||||||
|
const failed = [];
|
||||||
|
|
||||||
|
for (const slug of input.slugs) {
|
||||||
|
try {
|
||||||
|
// Get the script details
|
||||||
|
const script = await localScriptsService.getScriptBySlug(slug);
|
||||||
|
if (!script) {
|
||||||
|
failed.push({ slug, error: 'Script not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the script files
|
||||||
|
const result = await scriptDownloaderService.loadScript(script);
|
||||||
|
if (result.success) {
|
||||||
|
successful.push({ slug, files: result.files });
|
||||||
|
} else {
|
||||||
|
const error = 'error' in result ? result.error : 'Failed to load script';
|
||||||
|
failed.push({ slug, error });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({
|
||||||
|
slug,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to load script'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Downloaded ${successful.length} scripts successfully, ${failed.length} failed`,
|
||||||
|
successful,
|
||||||
|
failed,
|
||||||
|
total: input.slugs.length
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in loadMultipleScripts:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to load multiple scripts',
|
||||||
|
successful: [],
|
||||||
|
failed: [],
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
// Check if script files exist locally
|
// Check if script files exist locally
|
||||||
checkScriptFiles: publicProcedure
|
checkScriptFiles: publicProcedure
|
||||||
.input(z.object({ slug: z.string() }))
|
.input(z.object({ slug: z.string() }))
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile, writeFile } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { existsSync, createWriteStream } from "fs";
|
||||||
|
import stripAnsi from "strip-ansi";
|
||||||
|
|
||||||
interface GitHubRelease {
|
interface GitHubRelease {
|
||||||
tag_name: string;
|
tag_name: string;
|
||||||
name: string;
|
name: string;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
html_url: string;
|
html_url: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to fetch from GitHub API with optional authentication
|
||||||
|
async function fetchGitHubAPI(url: string) {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'ProxmoxVE-Local'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add authentication header if token is available
|
||||||
|
if (env.GITHUB_TOKEN) {
|
||||||
|
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const versionRouter = createTRPCRouter({
|
export const versionRouter = createTRPCRouter({
|
||||||
@@ -34,7 +53,7 @@ export const versionRouter = createTRPCRouter({
|
|||||||
getLatestRelease: publicProcedure
|
getLatestRelease: publicProcedure
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`GitHub API error: ${response.status}`);
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
@@ -70,7 +89,7 @@ export const versionRouter = createTRPCRouter({
|
|||||||
const currentVersion = (await readFile(versionPath, 'utf-8')).trim();
|
const currentVersion = (await readFile(versionPath, 'utf-8')).trim();
|
||||||
|
|
||||||
|
|
||||||
const response = await fetch('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`GitHub API error: ${response.status}`);
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
@@ -109,21 +128,117 @@ export const versionRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Get all releases for release notes
|
||||||
|
getAllReleases: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases: GitHubRelease[] = await response.json();
|
||||||
|
|
||||||
|
// Sort by published date (newest first)
|
||||||
|
const sortedReleases = releases
|
||||||
|
.filter(release => !release.tag_name.includes('beta') && !release.tag_name.includes('alpha'))
|
||||||
|
.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime());
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
releases: sortedReleases.map(release => ({
|
||||||
|
tagName: release.tag_name,
|
||||||
|
name: release.name,
|
||||||
|
publishedAt: release.published_at,
|
||||||
|
htmlUrl: release.html_url,
|
||||||
|
body: release.body
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching all releases:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch releases',
|
||||||
|
releases: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get update logs from the log file
|
||||||
|
getUpdateLogs: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
try {
|
||||||
|
const logPath = join(process.cwd(), 'update.log');
|
||||||
|
|
||||||
|
if (!existsSync(logPath)) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
logs: [],
|
||||||
|
isComplete: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await readFile(logPath, 'utf-8');
|
||||||
|
const logLines = logs.split('\n')
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map(line => stripAnsi(line)); // Strip ANSI color codes
|
||||||
|
|
||||||
|
// Check if update is complete by looking for completion indicators
|
||||||
|
const isComplete = logLines.some(line =>
|
||||||
|
line.includes('Update complete') ||
|
||||||
|
line.includes('Server restarting') ||
|
||||||
|
line.includes('npm start') ||
|
||||||
|
line.includes('Restarting server') ||
|
||||||
|
line.includes('Server started') ||
|
||||||
|
line.includes('Ready on http') ||
|
||||||
|
line.includes('Application started') ||
|
||||||
|
line.includes('Service enabled and started successfully') ||
|
||||||
|
line.includes('Service is running') ||
|
||||||
|
line.includes('Update completed successfully')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
logs: logLines,
|
||||||
|
isComplete
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading update logs:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to read update logs',
|
||||||
|
logs: [],
|
||||||
|
isComplete: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
// Execute update script
|
// Execute update script
|
||||||
executeUpdate: publicProcedure
|
executeUpdate: publicProcedure
|
||||||
.mutation(async () => {
|
.mutation(async () => {
|
||||||
try {
|
try {
|
||||||
const updateScriptPath = join(process.cwd(), 'update.sh');
|
const updateScriptPath = join(process.cwd(), 'update.sh');
|
||||||
|
const logPath = join(process.cwd(), 'update.log');
|
||||||
|
|
||||||
|
// Clear/create the log file
|
||||||
|
await writeFile(logPath, '', 'utf-8');
|
||||||
|
|
||||||
// Spawn the update script as a detached process using nohup
|
// Spawn the update script as a detached process using nohup
|
||||||
// This allows it to run independently and kill the parent Node.js process
|
// This allows it to run independently and kill the parent Node.js process
|
||||||
const child = spawn('nohup', ['bash', updateScriptPath], {
|
// Redirect output to log file
|
||||||
|
const child = spawn('bash', [updateScriptPath], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: ['ignore', 'ignore', 'ignore'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
shell: false,
|
shell: false,
|
||||||
detached: true
|
detached: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Capture stdout and stderr to log file
|
||||||
|
const logStream = createWriteStream(logPath, { flags: 'a' });
|
||||||
|
child.stdout?.pipe(logStream);
|
||||||
|
child.stderr?.pipe(logStream);
|
||||||
|
|
||||||
// Unref the child process so it doesn't keep the parent alive
|
// Unref the child process so it doesn't keep the parent alive
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ export class ScriptExecutionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleConnection(ws: WebSocket, _request: IncomingMessage) {
|
private handleConnection(ws: WebSocket, _request: IncomingMessage) {
|
||||||
|
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
||||||
const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string };
|
const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string };
|
||||||
void this.handleMessage(ws, message);
|
void this.handleMessage(ws, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -40,20 +43,20 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
|
|
||||||
// Clean up any active executions for this connection
|
// Clean up any active executions for this connection
|
||||||
this.cleanupActiveExecutions(ws);
|
this.cleanupActiveExecutions(ws);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
ws.on('error', (_error) => {
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
this.cleanupActiveExecutions(ws);
|
this.cleanupActiveExecutions(ws);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any }) {
|
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any; input?: string }) {
|
||||||
const { action, scriptPath, executionId, mode, server } = message;
|
const { action, scriptPath, executionId, mode, server, input } = message;
|
||||||
|
|
||||||
console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
|
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
@@ -74,6 +77,20 @@ export class ScriptExecutionHandler {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'input':
|
||||||
|
if (executionId && input !== undefined) {
|
||||||
|
|
||||||
|
this.sendInputToExecution(executionId, input);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: 'Missing executionId or input data',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -84,8 +101,7 @@ export class ScriptExecutionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) {
|
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) {
|
||||||
console.log('startScriptExecution called with:', { scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if execution is already running
|
// Check if execution is already running
|
||||||
if (this.activeExecutions.has(executionId)) {
|
if (this.activeExecutions.has(executionId)) {
|
||||||
@@ -100,10 +116,7 @@ export class ScriptExecutionHandler {
|
|||||||
let process: any;
|
let process: any;
|
||||||
|
|
||||||
if (mode === 'ssh' && server) {
|
if (mode === 'ssh' && server) {
|
||||||
// SSH execution
|
|
||||||
console.log('Starting SSH execution:', { scriptPath, server });
|
|
||||||
console.log('SSH execution mode detected, calling SSH service...');
|
|
||||||
console.log('Mode check: mode=', mode, 'server=', !!server);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'start',
|
type: 'start',
|
||||||
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
|
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
|
||||||
@@ -111,13 +124,11 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sshService = getSSHExecutionService();
|
const sshService = getSSHExecutionService();
|
||||||
console.log('SSH service obtained, calling executeScript...');
|
|
||||||
console.log('SSH service object:', typeof sshService, sshService.constructor.name);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sshService.executeScript(server as Server, scriptPath,
|
const result = await sshService.executeScript(server as Server, scriptPath,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
console.log('SSH onData callback:', data.substring(0, 100) + '...');
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'output',
|
type: 'output',
|
||||||
data: data,
|
data: data,
|
||||||
@@ -125,7 +136,7 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
console.log('SSH onError callback:', error);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
data: error,
|
data: error,
|
||||||
@@ -133,7 +144,7 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
(code: number) => {
|
(code: number) => {
|
||||||
console.log('SSH onExit callback, code:', code);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'end',
|
type: 'end',
|
||||||
data: `SSH script execution finished with code: ${code}`,
|
data: `SSH script execution finished with code: ${code}`,
|
||||||
@@ -142,10 +153,10 @@ export class ScriptExecutionHandler {
|
|||||||
this.activeExecutions.delete(executionId);
|
this.activeExecutions.delete(executionId);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
console.log('SSH service executeScript completed, result:', result);
|
|
||||||
process = (result as any).process;
|
process = (result as any).process;
|
||||||
} catch (sshError) {
|
} catch (sshError) {
|
||||||
console.error('SSH service executeScript failed:', sshError);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
|
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
|
||||||
@@ -154,10 +165,7 @@ export class ScriptExecutionHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Local execution
|
|
||||||
console.log('Starting local execution:', { scriptPath });
|
|
||||||
console.log('Local execution mode detected, calling local script manager...');
|
|
||||||
console.log('Mode check: mode=', mode, 'server=', !!server, 'condition result:', mode === 'ssh' && server);
|
|
||||||
|
|
||||||
// Validate script path
|
// Validate script path
|
||||||
const validation = scriptManager.validateScriptPath(scriptPath);
|
const validation = scriptManager.validateScriptPath(scriptPath);
|
||||||
@@ -249,6 +257,59 @@ export class ScriptExecutionHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sendInputToExecution(executionId: string, input: string) {
|
||||||
|
|
||||||
|
const execution = this.activeExecutions.get(executionId);
|
||||||
|
|
||||||
|
|
||||||
|
if (execution?.process) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if it's a pty process (SSH) or regular process
|
||||||
|
if (typeof execution.process.write === 'function' && !execution.process.stdin) {
|
||||||
|
|
||||||
|
|
||||||
|
execution.process.write(input);
|
||||||
|
|
||||||
|
|
||||||
|
// Send confirmation back to client
|
||||||
|
this.sendMessage(execution.ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else if (execution.process.stdin && !execution.process.stdin.destroyed) {
|
||||||
|
|
||||||
|
execution.process.stdin.write(input);
|
||||||
|
|
||||||
|
|
||||||
|
this.sendMessage(execution.ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
|
||||||
|
this.sendMessage(execution.ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: 'Process input not available',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
this.sendMessage(execution.ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: `Failed to send input: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No active execution found - this case is already handled above
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) {
|
private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify(message));
|
ws.send(JSON.stringify(message));
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
class DatabaseService {
|
class DatabaseService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -9,6 +11,12 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
// Ensure data/ssh-keys directory exists
|
||||||
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
|
if (!existsSync(sshKeysDir)) {
|
||||||
|
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||||
|
}
|
||||||
|
|
||||||
// Create servers table if it doesn't exist
|
// Create servers table if it doesn't exist
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS servers (
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
@@ -16,12 +24,114 @@ class DatabaseService {
|
|||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
ip TEXT NOT NULL,
|
ip TEXT NOT NULL,
|
||||||
user TEXT NOT NULL,
|
user TEXT NOT NULL,
|
||||||
password TEXT NOT NULL,
|
password TEXT,
|
||||||
|
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key')),
|
||||||
|
ssh_key TEXT,
|
||||||
|
ssh_key_passphrase TEXT,
|
||||||
|
ssh_port INTEGER DEFAULT 22,
|
||||||
|
ssh_key_path TEXT,
|
||||||
|
key_generated INTEGER DEFAULT 0,
|
||||||
|
color TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: Add new columns to existing servers table
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key'))
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_key TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_key_passphrase TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_port INTEGER DEFAULT 22
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN color TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_key_path TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN key_generated INTEGER DEFAULT 0
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing servers to have auth_type='password' if not set
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update existing servers to have ssh_port=22 if not set
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migration: Convert 'both' auth_type to 'key'
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE servers SET auth_type = 'key' WHERE auth_type = 'both'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update existing servers to have key_generated=0 if not set
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE servers SET key_generated = 0 WHERE key_generated IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migration: Add web_ui_ip column to existing installed_scripts table
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE installed_scripts ADD COLUMN web_ui_ip TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: Add web_ui_port column to existing installed_scripts table
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE installed_scripts ADD COLUMN web_ui_port INTEGER
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
// Create installed_scripts table if it doesn't exist
|
// Create installed_scripts table if it doesn't exist
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS installed_scripts (
|
CREATE TABLE IF NOT EXISTS installed_scripts (
|
||||||
@@ -34,6 +144,8 @@ class DatabaseService {
|
|||||||
installation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
installation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')),
|
status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')),
|
||||||
output_log TEXT,
|
output_log TEXT,
|
||||||
|
web_ui_ip TEXT,
|
||||||
|
web_ui_port INTEGER,
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
@@ -53,12 +165,21 @@ class DatabaseService {
|
|||||||
* @param {import('../types/server').CreateServerData} serverData
|
* @param {import('../types/server').CreateServerData} serverData
|
||||||
*/
|
*/
|
||||||
createServer(serverData) {
|
createServer(serverData) {
|
||||||
const { name, ip, user, password } = serverData;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||||
|
|
||||||
|
let ssh_key_path = null;
|
||||||
|
|
||||||
|
// If using SSH key authentication, create persistent key file
|
||||||
|
if (auth_type === 'key' && ssh_key) {
|
||||||
|
const serverId = this.getNextServerId();
|
||||||
|
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||||
|
}
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
INSERT INTO servers (name, ip, user, password)
|
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, ssh_key_path, key_generated, color)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
return stmt.run(name, ip, user, password);
|
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, ssh_key_path, key_generated || 0, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllServers() {
|
getAllServers() {
|
||||||
@@ -79,19 +200,85 @@ class DatabaseService {
|
|||||||
* @param {import('../types/server').CreateServerData} serverData
|
* @param {import('../types/server').CreateServerData} serverData
|
||||||
*/
|
*/
|
||||||
updateServer(id, serverData) {
|
updateServer(id, serverData) {
|
||||||
const { name, ip, user, password } = serverData;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||||
|
|
||||||
|
// Get existing server to check for key changes
|
||||||
|
const existingServer = this.getServerById(id);
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
let ssh_key_path = existingServer?.ssh_key_path;
|
||||||
|
|
||||||
|
// Handle SSH key changes
|
||||||
|
if (auth_type === 'key' && ssh_key) {
|
||||||
|
// Delete old key file if it exists
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
unlinkSync(existingServer.ssh_key_path);
|
||||||
|
// Also delete public key file if it exists
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete old SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new key file
|
||||||
|
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
|
||||||
|
} else if (auth_type !== 'key') {
|
||||||
|
// If switching away from key auth, delete key files
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
unlinkSync(existingServer.ssh_key_path);
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ssh_key_path = null;
|
||||||
|
}
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
UPDATE servers
|
UPDATE servers
|
||||||
SET name = ?, ip = ?, user = ?, password = ?
|
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, ssh_key_path = ?, key_generated = ?, color = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
return stmt.run(name, ip, user, password, id);
|
// @ts-ignore - Database migration adds this column
|
||||||
|
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, ssh_key_path, key_generated !== undefined ? key_generated : (existingServer?.key_generated || 0), color, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} id
|
* @param {number} id
|
||||||
*/
|
*/
|
||||||
deleteServer(id) {
|
deleteServer(id) {
|
||||||
|
// Get server info before deletion to clean up key files
|
||||||
|
const server = this.getServerById(id);
|
||||||
|
|
||||||
|
// Delete SSH key files if they exist
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
unlinkSync(server.ssh_key_path);
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
const pubKeyPath = server.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
|
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
|
||||||
return stmt.run(id);
|
return stmt.run(id);
|
||||||
}
|
}
|
||||||
@@ -106,14 +293,16 @@ class DatabaseService {
|
|||||||
* @param {string} scriptData.execution_mode
|
* @param {string} scriptData.execution_mode
|
||||||
* @param {string} scriptData.status
|
* @param {string} scriptData.status
|
||||||
* @param {string} [scriptData.output_log]
|
* @param {string} [scriptData.output_log]
|
||||||
|
* @param {string} [scriptData.web_ui_ip]
|
||||||
|
* @param {number} [scriptData.web_ui_port]
|
||||||
*/
|
*/
|
||||||
createInstalledScript(scriptData) {
|
createInstalledScript(scriptData) {
|
||||||
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log } = scriptData;
|
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log)
|
INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null);
|
return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null, web_ui_ip || null, web_ui_port || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllInstalledScripts() {
|
getAllInstalledScripts() {
|
||||||
@@ -123,7 +312,12 @@ class DatabaseService {
|
|||||||
s.name as server_name,
|
s.name as server_name,
|
||||||
s.ip as server_ip,
|
s.ip as server_ip,
|
||||||
s.user as server_user,
|
s.user as server_user,
|
||||||
s.password as server_password
|
s.password as server_password,
|
||||||
|
s.auth_type as server_auth_type,
|
||||||
|
s.ssh_key as server_ssh_key,
|
||||||
|
s.ssh_key_passphrase as server_ssh_key_passphrase,
|
||||||
|
s.ssh_port as server_ssh_port,
|
||||||
|
s.color as server_color
|
||||||
FROM installed_scripts inst
|
FROM installed_scripts inst
|
||||||
LEFT JOIN servers s ON inst.server_id = s.id
|
LEFT JOIN servers s ON inst.server_id = s.id
|
||||||
ORDER BY inst.installation_date DESC
|
ORDER BY inst.installation_date DESC
|
||||||
@@ -171,9 +365,11 @@ class DatabaseService {
|
|||||||
* @param {string} [updateData.container_id]
|
* @param {string} [updateData.container_id]
|
||||||
* @param {string} [updateData.status]
|
* @param {string} [updateData.status]
|
||||||
* @param {string} [updateData.output_log]
|
* @param {string} [updateData.output_log]
|
||||||
|
* @param {string} [updateData.web_ui_ip]
|
||||||
|
* @param {number} [updateData.web_ui_port]
|
||||||
*/
|
*/
|
||||||
updateInstalledScript(id, updateData) {
|
updateInstalledScript(id, updateData) {
|
||||||
const { script_name, container_id, status, output_log } = updateData;
|
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
|
|
||||||
@@ -193,6 +389,14 @@ class DatabaseService {
|
|||||||
updates.push('output_log = ?');
|
updates.push('output_log = ?');
|
||||||
values.push(output_log);
|
values.push(output_log);
|
||||||
}
|
}
|
||||||
|
if (web_ui_ip !== undefined) {
|
||||||
|
updates.push('web_ui_ip = ?');
|
||||||
|
values.push(web_ui_ip);
|
||||||
|
}
|
||||||
|
if (web_ui_port !== undefined) {
|
||||||
|
updates.push('web_ui_port = ?');
|
||||||
|
values.push(web_ui_port);
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
return { changes: 0 };
|
return { changes: 0 };
|
||||||
@@ -215,6 +419,43 @@ class DatabaseService {
|
|||||||
return stmt.run(id);
|
return stmt.run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} server_id
|
||||||
|
*/
|
||||||
|
deleteInstalledScriptsByServer(server_id) {
|
||||||
|
const stmt = this.db.prepare('DELETE FROM installed_scripts WHERE server_id = ?');
|
||||||
|
return stmt.run(server_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next available server ID for key file naming
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getNextServerId() {
|
||||||
|
const stmt = this.db.prepare('SELECT MAX(id) as maxId FROM servers');
|
||||||
|
const result = stmt.get();
|
||||||
|
// @ts-ignore - SQL query result type
|
||||||
|
return (result?.maxId || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create SSH key file and return the path
|
||||||
|
* @param {number} serverId
|
||||||
|
* @param {string} sshKey
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
createSSHKeyFile(serverId, sshKey) {
|
||||||
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
|
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||||
|
|
||||||
|
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||||
|
const normalizedKey = sshKey.trimEnd() + '\n';
|
||||||
|
writeFileSync(keyPath, normalizedKey);
|
||||||
|
chmodSync(keyPath, 0o600); // Set proper permissions
|
||||||
|
|
||||||
|
return keyPath;
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,95 @@ export class ScriptManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all downloaded scripts from all directories (ct, tools, vm, vw)
|
||||||
|
*/
|
||||||
|
async getAllDownloadedScripts(): Promise<ScriptInfo[]> {
|
||||||
|
this.initializeConfig();
|
||||||
|
const allScripts: ScriptInfo[] = [];
|
||||||
|
|
||||||
|
// Define all script directories to scan
|
||||||
|
const scriptDirs = ['ct', 'tools', 'vm', 'vw'];
|
||||||
|
|
||||||
|
for (const dirName of scriptDirs) {
|
||||||
|
try {
|
||||||
|
const dirPath = join(this.scriptsDir!, dirName);
|
||||||
|
|
||||||
|
// Check if directory exists
|
||||||
|
try {
|
||||||
|
await stat(dirPath);
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist, skip it
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scripts = await this.getScriptsFromDirectory(dirPath);
|
||||||
|
allScripts.push(...scripts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading ${dirName} scripts directory:`, error);
|
||||||
|
// Continue with other directories even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allScripts.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scripts from a specific directory (recursively)
|
||||||
|
*/
|
||||||
|
private async getScriptsFromDirectory(dirPath: string): Promise<ScriptInfo[]> {
|
||||||
|
const scripts: ScriptInfo[] = [];
|
||||||
|
|
||||||
|
const scanDirectory = async (currentPath: string, relativePath = ''): Promise<void> => {
|
||||||
|
const files = await readdir(currentPath);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = join(currentPath, file);
|
||||||
|
const stats = await stat(filePath);
|
||||||
|
|
||||||
|
if (stats.isFile()) {
|
||||||
|
const extension = extname(file);
|
||||||
|
|
||||||
|
// Check if file extension is allowed
|
||||||
|
if (this.allowedExtensions!.includes(extension)) {
|
||||||
|
// Check if file is executable
|
||||||
|
const executable = await this.isExecutable(filePath);
|
||||||
|
|
||||||
|
// Extract slug from filename (remove .sh extension)
|
||||||
|
const slug = file.replace(/\.sh$/, '');
|
||||||
|
|
||||||
|
// Try to get logo from JSON data
|
||||||
|
let logo: string | undefined;
|
||||||
|
try {
|
||||||
|
const scriptData = await localScriptsService.getScriptBySlug(slug);
|
||||||
|
logo = scriptData?.logo ?? undefined;
|
||||||
|
} catch {
|
||||||
|
// JSON file might not exist, that's okay
|
||||||
|
}
|
||||||
|
|
||||||
|
scripts.push({
|
||||||
|
name: file,
|
||||||
|
path: filePath,
|
||||||
|
extension,
|
||||||
|
size: stats.size,
|
||||||
|
lastModified: stats.mtime,
|
||||||
|
executable,
|
||||||
|
logo,
|
||||||
|
slug
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (stats.isDirectory()) {
|
||||||
|
// Recursively scan subdirectories
|
||||||
|
const subRelativePath = relativePath ? join(relativePath, file) : file;
|
||||||
|
await scanDirectory(filePath, subRelativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await scanDirectory(dirPath);
|
||||||
|
return scripts;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a file is executable
|
* Check if a file is executable
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,16 +1,83 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { spawn as ptySpawn } from 'node-pty';
|
import { spawn as ptySpawn } from 'node-pty';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Server
|
* @typedef {Object} Server
|
||||||
* @property {string} ip - Server IP address
|
* @property {string} ip - Server IP address
|
||||||
* @property {string} user - Username
|
* @property {string} user - Username
|
||||||
* @property {string} password - Password
|
* @property {string} [password] - Password (optional)
|
||||||
* @property {string} name - Server name
|
* @property {string} name - Server name
|
||||||
|
* @property {string} [auth_type] - Authentication type ('password', 'key')
|
||||||
|
* @property {string} [ssh_key] - SSH private key content
|
||||||
|
* @property {string} [ssh_key_passphrase] - SSH key passphrase
|
||||||
|
* @property {string} [ssh_key_path] - Path to persistent SSH key file
|
||||||
|
* @property {number} [ssh_port] - SSH port (default: 22)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class SSHExecutionService {
|
class SSHExecutionService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SSH command arguments based on authentication type
|
||||||
|
* @param {Server} server - Server configuration
|
||||||
|
* @returns {{command: string, args: string[]}} Command and arguments for SSH
|
||||||
|
*/
|
||||||
|
buildSSHCommand(server) {
|
||||||
|
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
|
||||||
|
|
||||||
|
const baseArgs = [
|
||||||
|
'-t',
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
|
'-o', 'LogLevel=ERROR',
|
||||||
|
'-o', 'RequestTTY=yes',
|
||||||
|
'-o', 'SetEnv=TERM=xterm-256color',
|
||||||
|
'-o', 'SetEnv=COLUMNS=120',
|
||||||
|
'-o', 'SetEnv=LINES=30',
|
||||||
|
'-o', 'SetEnv=COLORTERM=truecolor',
|
||||||
|
'-o', 'SetEnv=FORCE_COLOR=1',
|
||||||
|
'-o', 'SetEnv=NO_COLOR=0',
|
||||||
|
'-o', 'SetEnv=CLICOLOR=1',
|
||||||
|
'-o', 'SetEnv=CLICOLOR_FORCE=1'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (auth_type === 'key') {
|
||||||
|
// SSH key authentication
|
||||||
|
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
||||||
|
throw new Error('SSH key file not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
baseArgs.push('-i', ssh_key_path);
|
||||||
|
baseArgs.push('-o', 'PasswordAuthentication=no');
|
||||||
|
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
||||||
|
|
||||||
|
if (ssh_key_passphrase) {
|
||||||
|
return {
|
||||||
|
command: 'sshpass',
|
||||||
|
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
command: 'ssh',
|
||||||
|
args: [...baseArgs, `${user}@${ip}`]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Password authentication (default)
|
||||||
|
if (password) {
|
||||||
|
return {
|
||||||
|
command: 'sshpass',
|
||||||
|
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Password is required for password authentication');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a script on a remote server via SSH
|
* Execute a script on a remote server via SSH
|
||||||
* @param {Server} server - Server configuration
|
* @param {Server} server - Server configuration
|
||||||
@@ -21,54 +88,38 @@ class SSHExecutionService {
|
|||||||
* @returns {Promise<Object>} Process information
|
* @returns {Promise<Object>} Process information
|
||||||
*/
|
*/
|
||||||
async executeScript(server, scriptPath, onData, onError, onExit) {
|
async executeScript(server, scriptPath, onData, onError, onExit) {
|
||||||
const { ip, user, password } = server;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.transferScriptsFolder(server, onData, onError);
|
await this.transferScriptsFolder(server, onData, onError);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
||||||
|
|
||||||
// Use ptySpawn for proper terminal emulation and color support
|
try {
|
||||||
const sshCommand = ptySpawn('sshpass', [
|
// Build SSH command based on authentication type
|
||||||
'-p', password,
|
const { command, args } = this.buildSSHCommand(server);
|
||||||
'ssh',
|
|
||||||
'-t',
|
// Add the script execution command to the args
|
||||||
'-o', 'ConnectTimeout=10',
|
args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`);
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
// Use ptySpawn for proper terminal emulation and color support
|
||||||
'-o', 'LogLevel=ERROR',
|
const sshCommand = ptySpawn(command, args, {
|
||||||
'-o', 'PasswordAuthentication=yes',
|
name: 'xterm-256color',
|
||||||
'-o', 'PubkeyAuthentication=no',
|
cols: 120,
|
||||||
'-o', 'RequestTTY=yes',
|
rows: 30,
|
||||||
'-o', 'SetEnv=TERM=xterm-256color',
|
cwd: process.cwd(),
|
||||||
'-o', 'SetEnv=COLUMNS=120',
|
env: {
|
||||||
'-o', 'SetEnv=LINES=30',
|
...process.env,
|
||||||
'-o', 'SetEnv=COLORTERM=truecolor',
|
TERM: 'xterm-256color',
|
||||||
'-o', 'SetEnv=FORCE_COLOR=1',
|
COLUMNS: '120',
|
||||||
'-o', 'SetEnv=NO_COLOR=0',
|
LINES: '30',
|
||||||
'-o', 'SetEnv=CLICOLOR=1',
|
SHELL: '/bin/bash',
|
||||||
'-o', 'SetEnv=CLICOLOR_FORCE=1',
|
COLORTERM: 'truecolor',
|
||||||
`${user}@${ip}`,
|
FORCE_COLOR: '1',
|
||||||
`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`
|
NO_COLOR: '0',
|
||||||
], {
|
CLICOLOR: '1',
|
||||||
name: 'xterm-256color',
|
CLICOLOR_FORCE: '1'
|
||||||
cols: 120,
|
}
|
||||||
rows: 30,
|
});
|
||||||
cwd: process.cwd(),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TERM: 'xterm-256color',
|
|
||||||
COLUMNS: '120',
|
|
||||||
LINES: '30',
|
|
||||||
SHELL: '/bin/bash',
|
|
||||||
COLORTERM: 'truecolor',
|
|
||||||
FORCE_COLOR: '1',
|
|
||||||
NO_COLOR: '0',
|
|
||||||
CLICOLOR: '1',
|
|
||||||
CLICOLOR_FORCE: '1'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use pty's onData method which handles both stdout and stderr combined
|
// Use pty's onData method which handles both stdout and stderr combined
|
||||||
sshCommand.onData((data) => {
|
sshCommand.onData((data) => {
|
||||||
@@ -82,8 +133,14 @@ class SSHExecutionService {
|
|||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
process: sshCommand,
|
process: sshCommand,
|
||||||
kill: () => sshCommand.kill('SIGTERM')
|
kill: () => {
|
||||||
|
sshCommand.kill('SIGTERM');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
@@ -100,20 +157,38 @@ class SSHExecutionService {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async transferScriptsFolder(server, onData, onError) {
|
async transferScriptsFolder(server, onData, onError) {
|
||||||
const { ip, user, password } = server;
|
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const rsyncCommand = spawn('rsync', [
|
try {
|
||||||
'-avz',
|
// Build rsync command based on authentication type
|
||||||
'--delete',
|
let rshCommand;
|
||||||
'--exclude=*.log',
|
if (auth_type === 'key') {
|
||||||
'--exclude=*.tmp',
|
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
||||||
'--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null',
|
throw new Error('SSH key file not found');
|
||||||
'scripts/',
|
}
|
||||||
`${user}@${ip}:/tmp/scripts/`
|
|
||||||
], {
|
if (ssh_key_passphrase) {
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
});
|
} else {
|
||||||
|
rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Password authentication
|
||||||
|
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsyncCommand = spawn('rsync', [
|
||||||
|
'-avz',
|
||||||
|
'--delete',
|
||||||
|
'--exclude=*.log',
|
||||||
|
'--exclude=*.tmp',
|
||||||
|
`--rsh=${rshCommand}`,
|
||||||
|
'scripts/',
|
||||||
|
`${user}@${ip}:/tmp/scripts/`
|
||||||
|
], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
|
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
|
||||||
// Ensure proper UTF-8 encoding for ANSI colors
|
// Ensure proper UTF-8 encoding for ANSI colors
|
||||||
@@ -138,6 +213,10 @@ class SSHExecutionService {
|
|||||||
rsyncCommand.on('error', (error) => {
|
rsyncCommand.on('error', (error) => {
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,37 +230,22 @@ class SSHExecutionService {
|
|||||||
* @returns {Promise<Object>} Process information
|
* @returns {Promise<Object>} Process information
|
||||||
*/
|
*/
|
||||||
async executeCommand(server, command, onData, onError, onExit) {
|
async executeCommand(server, command, onData, onError, onExit) {
|
||||||
const { ip, user, password } = server;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Use ptySpawn for proper terminal emulation and color support
|
try {
|
||||||
const sshCommand = ptySpawn('sshpass', [
|
// Build SSH command based on authentication type
|
||||||
'-p', password,
|
const { command: sshCommandName, args } = this.buildSSHCommand(server);
|
||||||
'ssh',
|
|
||||||
'-t',
|
// Add the command to execute to the args
|
||||||
'-o', 'ConnectTimeout=10',
|
args.push(command);
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
// Use ptySpawn for proper terminal emulation and color support
|
||||||
'-o', 'LogLevel=ERROR',
|
const sshCommand = ptySpawn(sshCommandName, args, {
|
||||||
'-o', 'PasswordAuthentication=yes',
|
name: 'xterm-color',
|
||||||
'-o', 'PubkeyAuthentication=no',
|
cols: 120,
|
||||||
'-o', 'RequestTTY=yes',
|
rows: 30,
|
||||||
'-o', 'SetEnv=TERM=xterm-256color',
|
cwd: process.cwd(),
|
||||||
'-o', 'SetEnv=COLUMNS=120',
|
env: process.env
|
||||||
'-o', 'SetEnv=LINES=30',
|
});
|
||||||
'-o', 'SetEnv=COLORTERM=truecolor',
|
|
||||||
'-o', 'SetEnv=FORCE_COLOR=1',
|
|
||||||
'-o', 'SetEnv=NO_COLOR=0',
|
|
||||||
'-o', 'SetEnv=CLICOLOR=1',
|
|
||||||
`${user}@${ip}`,
|
|
||||||
command
|
|
||||||
], {
|
|
||||||
name: 'xterm-color',
|
|
||||||
cols: 120,
|
|
||||||
rows: 30,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: process.env
|
|
||||||
});
|
|
||||||
|
|
||||||
sshCommand.onData((data) => {
|
sshCommand.onData((data) => {
|
||||||
onData(data);
|
onData(data);
|
||||||
@@ -191,7 +255,16 @@ class SSHExecutionService {
|
|||||||
onExit(e.exitCode);
|
onExit(e.exitCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
resolve({ process: sshCommand });
|
resolve({
|
||||||
|
process: sshCommand,
|
||||||
|
kill: () => {
|
||||||
|
sshCommand.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { writeFileSync, unlinkSync, chmodSync } from 'fs';
|
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync, readFileSync, existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
class SSHService {
|
class SSHService {
|
||||||
/**
|
/**
|
||||||
@@ -10,38 +11,39 @@ class SSHService {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testConnection(server) {
|
async testConnection(server) {
|
||||||
const { ip, user, password } = server;
|
const { auth_type = 'password' } = server;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const timeout = 15000; // 15 seconds timeout for login test
|
const timeout = 15000; // 15 seconds timeout for login test
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
// Try sshpass first if available
|
// Choose authentication method based on auth_type
|
||||||
this.testWithSshpass(server).then(result => {
|
let authPromise;
|
||||||
|
if (auth_type === 'key') {
|
||||||
|
authPromise = this.testWithSSHKey(server);
|
||||||
|
} else {
|
||||||
|
// Default to password authentication
|
||||||
|
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
authPromise.then(result => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
resolve(result);
|
resolve(result);
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// If sshpass fails, try expect
|
// If primary method fails, return error
|
||||||
this.testWithExpect(server).then(result => {
|
if (!resolved) {
|
||||||
if (!resolved) {
|
resolved = true;
|
||||||
resolved = true;
|
resolve({
|
||||||
resolve(result);
|
success: false,
|
||||||
}
|
message: `SSH login test failed for ${auth_type} authentication`,
|
||||||
}).catch(() => {
|
details: {
|
||||||
// If both fail, return error
|
method: 'auth_failed',
|
||||||
if (!resolved) {
|
auth_type: auth_type
|
||||||
resolved = true;
|
}
|
||||||
resolve({
|
});
|
||||||
success: false,
|
}
|
||||||
message: 'SSH login test requires sshpass or expect - neither available or working',
|
|
||||||
details: {
|
|
||||||
method: 'no_auth_tools'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up overall timeout
|
// Set up overall timeout
|
||||||
@@ -64,7 +66,11 @@ class SSHService {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testWithSshpass(server) {
|
async testWithSshpass(server) {
|
||||||
const { ip, user, password } = server;
|
const { ip, user, password, ssh_port = 22 } = server;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
throw new Error('Password is required for password authentication');
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = 10000;
|
const timeout = 10000;
|
||||||
@@ -73,6 +79,7 @@ class SSHService {
|
|||||||
const sshCommand = spawn('sshpass', [
|
const sshCommand = spawn('sshpass', [
|
||||||
'-p', password,
|
'-p', password,
|
||||||
'ssh',
|
'ssh',
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
'-o', 'ConnectTimeout=10',
|
'-o', 'ConnectTimeout=10',
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
@@ -156,7 +163,7 @@ class SSHService {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testWithExpect(server) {
|
async testWithExpect(server) {
|
||||||
const { ip, user, password } = server;
|
const { ip, user, password, ssh_port = 22 } = server;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = 10000;
|
const timeout = 10000;
|
||||||
@@ -164,7 +171,7 @@ class SSHService {
|
|||||||
|
|
||||||
const expectScript = `#!/usr/bin/expect -f
|
const expectScript = `#!/usr/bin/expect -f
|
||||||
set timeout 10
|
set timeout 10
|
||||||
spawn ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
|
spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
|
||||||
expect {
|
expect {
|
||||||
"password:" {
|
"password:" {
|
||||||
send "${password}\r"
|
send "${password}\r"
|
||||||
@@ -428,13 +435,14 @@ expect {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testSSHConnection(server) {
|
async testSSHConnection(server) {
|
||||||
const { ip, user } = server;
|
const { ip, user, ssh_port = 22 } = server;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const timeout = 5000;
|
const timeout = 5000;
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
const sshCommand = spawn('ssh', [
|
const sshCommand = spawn('ssh', [
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
'-o', 'ConnectTimeout=5',
|
'-o', 'ConnectTimeout=5',
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
@@ -523,6 +531,199 @@ expect {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SSH connection using SSH key authentication
|
||||||
|
* @param {import('../types/server').Server} server - Server configuration
|
||||||
|
* @returns {Promise<Object>} Connection test result
|
||||||
|
*/
|
||||||
|
async testWithSSHKey(server) {
|
||||||
|
const { ip, user, ssh_key_path, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||||
|
|
||||||
|
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
||||||
|
throw new Error('SSH key file not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = 10000;
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build SSH command
|
||||||
|
const sshArgs = [
|
||||||
|
'-i', ssh_key_path,
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
|
'-o', 'LogLevel=ERROR',
|
||||||
|
'-o', 'PasswordAuthentication=no',
|
||||||
|
'-o', 'PubkeyAuthentication=yes',
|
||||||
|
`${user}@${ip}`,
|
||||||
|
'echo "SSH_LOGIN_SUCCESS"'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Use sshpass if passphrase is provided
|
||||||
|
let command, args;
|
||||||
|
if (ssh_key_passphrase) {
|
||||||
|
command = 'sshpass';
|
||||||
|
args = ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...sshArgs];
|
||||||
|
} else {
|
||||||
|
command = 'ssh';
|
||||||
|
args = sshArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshCommand = spawn(command, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
sshCommand.kill('SIGTERM');
|
||||||
|
reject(new Error('SSH key login timeout'));
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
sshCommand.stdout.on('data', (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshCommand.stderr.on('data', (data) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshCommand.on('close', (code) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (code === 0 && output.includes('SSH_LOGIN_SUCCESS')) {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
message: 'SSH key authentication successful - credentials verified',
|
||||||
|
details: {
|
||||||
|
server: server.name || 'Unknown',
|
||||||
|
ip: ip,
|
||||||
|
user: user,
|
||||||
|
method: 'ssh_key_verified'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let errorMessage = 'SSH key authentication failed';
|
||||||
|
|
||||||
|
if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) {
|
||||||
|
errorMessage = 'SSH key authentication failed - check key and permissions';
|
||||||
|
} else if (errorOutput.includes('Connection refused')) {
|
||||||
|
errorMessage = 'Connection refused - server may be down or SSH not running';
|
||||||
|
} else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) {
|
||||||
|
errorMessage = 'Host not found - check IP address';
|
||||||
|
} else if (errorOutput.includes('Connection timed out')) {
|
||||||
|
errorMessage = 'Connection timeout - server may be unreachable';
|
||||||
|
} else if (errorOutput.includes('Load key') || errorOutput.includes('invalid format')) {
|
||||||
|
errorMessage = 'Invalid SSH key format';
|
||||||
|
} else if (errorOutput.includes('Enter passphrase')) {
|
||||||
|
errorMessage = 'SSH key passphrase required but not provided';
|
||||||
|
} else {
|
||||||
|
errorMessage = `SSH key authentication failed: ${errorOutput.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sshCommand.on('error', (error) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SSH key pair for a server
|
||||||
|
* @param {number} serverId - Server ID for key file naming
|
||||||
|
* @returns {Promise<{privateKey: string, publicKey: string}>}
|
||||||
|
*/
|
||||||
|
async generateKeyPair(serverId) {
|
||||||
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
|
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sshKeygen = spawn('ssh-keygen', [
|
||||||
|
'-t', 'ed25519',
|
||||||
|
'-f', keyPath,
|
||||||
|
'-N', '', // No passphrase
|
||||||
|
'-C', 'pve-scripts-local'
|
||||||
|
], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
sshKeygen.stderr.on('data', (data) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshKeygen.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
try {
|
||||||
|
// Read the generated private key
|
||||||
|
const privateKey = readFileSync(keyPath, 'utf8');
|
||||||
|
|
||||||
|
// Read the generated public key
|
||||||
|
const publicKeyPath = keyPath + '.pub';
|
||||||
|
const publicKey = readFileSync(publicKeyPath, 'utf8');
|
||||||
|
|
||||||
|
// Set proper permissions
|
||||||
|
chmodSync(keyPath, 0o600);
|
||||||
|
chmodSync(publicKeyPath, 0o644);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
privateKey,
|
||||||
|
publicKey: publicKey.trim()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Failed to read generated key files: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error(`ssh-keygen failed: ${errorOutput}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sshKeygen.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to run ssh-keygen: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public key from private key file
|
||||||
|
* @param {string} keyPath - Path to private key file
|
||||||
|
* @returns {string} Public key content
|
||||||
|
*/
|
||||||
|
getPublicKey(keyPath) {
|
||||||
|
const publicKeyPath = keyPath + '.pub';
|
||||||
|
|
||||||
|
if (!existsSync(publicKeyPath)) {
|
||||||
|
throw new Error('Public key file not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileSync(publicKeyPath, 'utf8').trim();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
/* Semantic color utility classes */
|
/* Semantic color utility classes */
|
||||||
.bg-background { background-color: hsl(var(--background)); }
|
.bg-background { background-color: hsl(var(--background)); }
|
||||||
.text-foreground { color: hsl(var(--foreground)); }
|
.text-foreground { color: hsl(var(--foreground)); }
|
||||||
.bg-card { background-color: hsl(var(--card)); }
|
.bg-card { background-color: hsl(var(--card)) !important; }
|
||||||
.text-card-foreground { color: hsl(var(--card-foreground)); }
|
.text-card-foreground { color: hsl(var(--card-foreground)); }
|
||||||
.bg-popover { background-color: hsl(var(--popover)); }
|
.bg-popover { background-color: hsl(var(--popover)); }
|
||||||
.text-popover-foreground { color: hsl(var(--popover-foreground)); }
|
.text-popover-foreground { color: hsl(var(--popover-foreground)); }
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
|
|
||||||
/* Terminal-specific styles for ANSI escape code rendering */
|
/* Terminal-specific styles for ANSI escape code rendering */
|
||||||
.terminal-output {
|
.terminal-output {
|
||||||
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,3 +142,94 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced terminal styling */
|
||||||
|
.xterm {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Set basic background - let ANSI colors work naturally */
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
background-color: #0d1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
background-color: #0d1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better selection colors */
|
||||||
|
.xterm .xterm-selection {
|
||||||
|
background-color: #264f78;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile-specific improvements */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* Improve touch targets */
|
||||||
|
button, .cursor-pointer {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better text sizing on mobile */
|
||||||
|
.text-xs {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve form elements on mobile */
|
||||||
|
input, select, textarea {
|
||||||
|
font-size: 16px; /* Prevents zoom on iOS */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better spacing for mobile */
|
||||||
|
.space-y-2 > * + * {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-4 > * + * {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve modal and overlay positioning */
|
||||||
|
.fixed.inset-0 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better scroll behavior */
|
||||||
|
.overflow-x-auto {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet improvements */
|
||||||
|
@media (min-width: 641px) and (max-width: 1024px) {
|
||||||
|
/* Better spacing for tablets */
|
||||||
|
.container {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure proper viewport handling */
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile terminal centering - simple approach */
|
||||||
|
.mobile-terminal {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
align-items: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-terminal .xterm {
|
||||||
|
margin: 0 auto !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export interface ScriptCard {
|
|||||||
categories?: number[];
|
categories?: number[];
|
||||||
categoryNames?: string[];
|
categoryNames?: string[];
|
||||||
date_created?: string;
|
date_created?: string;
|
||||||
|
os?: string;
|
||||||
|
version?: string;
|
||||||
|
interface_port?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubFile {
|
export interface GitHubFile {
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ export interface Server {
|
|||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password?: string;
|
||||||
|
auth_type?: 'password' | 'key';
|
||||||
|
ssh_key?: string;
|
||||||
|
ssh_key_passphrase?: string;
|
||||||
|
ssh_key_path?: string;
|
||||||
|
key_generated?: number;
|
||||||
|
ssh_port?: number;
|
||||||
|
color?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -12,7 +19,14 @@ export interface CreateServerData {
|
|||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password?: string;
|
||||||
|
auth_type?: 'password' | 'key';
|
||||||
|
ssh_key?: string;
|
||||||
|
ssh_key_passphrase?: string;
|
||||||
|
ssh_key_path?: string;
|
||||||
|
key_generated?: number;
|
||||||
|
ssh_port?: number;
|
||||||
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateServerData extends CreateServerData {
|
export interface UpdateServerData extends CreateServerData {
|
||||||
|
|||||||
572
update.sh
572
update.sh
@@ -16,6 +16,13 @@ BACKUP_DIR="/tmp/pve-scripts-backup-$(date +%Y%m%d-%H%M%S)"
|
|||||||
DATA_DIR="./data"
|
DATA_DIR="./data"
|
||||||
LOG_FILE="/tmp/update.log"
|
LOG_FILE="/tmp/update.log"
|
||||||
|
|
||||||
|
# GitHub Personal Access Token for higher rate limits (optional)
|
||||||
|
# Set GITHUB_TOKEN environment variable or create .github_token file
|
||||||
|
GITHUB_TOKEN=""
|
||||||
|
|
||||||
|
# Global variable to track if service was running before update
|
||||||
|
SERVICE_WAS_RUNNING=false
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -23,6 +30,44 @@ YELLOW='\033[1;33m'
|
|||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Load GitHub token
|
||||||
|
load_github_token() {
|
||||||
|
# Try environment variable first
|
||||||
|
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||||
|
log "Using GitHub token from environment variable"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try .env file
|
||||||
|
if [ -f ".env" ]; then
|
||||||
|
local env_token
|
||||||
|
env_token=$(grep "^GITHUB_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' | tr -d "'" | tr -d '\n\r')
|
||||||
|
if [ -n "$env_token" ]; then
|
||||||
|
GITHUB_TOKEN="$env_token"
|
||||||
|
log "Using GitHub token from .env file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try .github_token file
|
||||||
|
if [ -f ".github_token" ]; then
|
||||||
|
GITHUB_TOKEN=$(cat .github_token | tr -d '\n\r')
|
||||||
|
log "Using GitHub token from .github_token file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try ~/.github_token file
|
||||||
|
if [ -f "$HOME/.github_token" ]; then
|
||||||
|
GITHUB_TOKEN=$(cat "$HOME/.github_token" | tr -d '\n\r')
|
||||||
|
log "Using GitHub token from ~/.github_token file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_warning "No GitHub token found. Using unauthenticated requests (lower rate limits)"
|
||||||
|
log_warning "To use a token, add GITHUB_TOKEN=your_token to .env file or set GITHUB_TOKEN environment variable"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# Initialize log file
|
# Initialize log file
|
||||||
init_log() {
|
init_log() {
|
||||||
# Clear/create log file
|
# Clear/create log file
|
||||||
@@ -83,8 +128,18 @@ check_dependencies() {
|
|||||||
get_latest_release() {
|
get_latest_release() {
|
||||||
log "Fetching latest release information from GitHub..."
|
log "Fetching latest release information from GitHub..."
|
||||||
|
|
||||||
|
local curl_opts="-s --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 3"
|
||||||
|
|
||||||
|
# Add authentication header if token is available
|
||||||
|
if [ -n "$GITHUB_TOKEN" ]; then
|
||||||
|
curl_opts="$curl_opts -H \"Authorization: token $GITHUB_TOKEN\""
|
||||||
|
log "Using authenticated GitHub API request"
|
||||||
|
else
|
||||||
|
log "Using unauthenticated GitHub API request (lower rate limits)"
|
||||||
|
fi
|
||||||
|
|
||||||
local release_info
|
local release_info
|
||||||
if ! release_info=$(curl -s --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 3 "$GITHUB_API/releases/latest"); then
|
if ! release_info=$(eval "curl $curl_opts \"$GITHUB_API/releases/latest\""); then
|
||||||
log_error "Failed to fetch release information from GitHub API (timeout or network error)"
|
log_error "Failed to fetch release information from GitHub API (timeout or network error)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -115,7 +170,7 @@ get_latest_release() {
|
|||||||
echo "$tag_name|$download_url"
|
echo "$tag_name|$download_url"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Backup data directory and .env file
|
# Backup data directory, .env file, and scripts directories
|
||||||
backup_data() {
|
backup_data() {
|
||||||
log "Creating backup directory at $BACKUP_DIR..."
|
log "Creating backup directory at $BACKUP_DIR..."
|
||||||
|
|
||||||
@@ -150,6 +205,23 @@ backup_data() {
|
|||||||
else
|
else
|
||||||
log_warning ".env file not found, skipping backup"
|
log_warning ".env file not found, skipping backup"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Backup scripts directories
|
||||||
|
local scripts_dirs=("scripts/ct" "scripts/install" "scripts/tools" "scripts/vm")
|
||||||
|
for scripts_dir in "${scripts_dirs[@]}"; do
|
||||||
|
if [ -d "$scripts_dir" ]; then
|
||||||
|
log "Backing up $scripts_dir directory..."
|
||||||
|
local backup_name=$(basename "$scripts_dir")
|
||||||
|
if ! cp -r "$scripts_dir" "$BACKUP_DIR/$backup_name"; then
|
||||||
|
log_error "Failed to backup $scripts_dir directory"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
log_success "$scripts_dir directory backed up successfully"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warning "$scripts_dir directory not found, skipping backup"
|
||||||
|
fi
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Download and extract latest release
|
# Download and extract latest release
|
||||||
@@ -170,53 +242,12 @@ download_release() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Download release with timeout and progress
|
# Download release with timeout and progress
|
||||||
log "Downloading from: $download_url"
|
if ! curl -L --connect-timeout 30 --max-time 300 --retry 3 --retry-delay 5 -o "$archive_file" "$download_url" 2>/dev/null; then
|
||||||
log "Target file: $archive_file"
|
log_error "Failed to download release from GitHub"
|
||||||
log "Starting curl download..."
|
|
||||||
|
|
||||||
# Test if curl is working
|
|
||||||
log "Testing curl availability..."
|
|
||||||
if ! command -v curl >/dev/null 2>&1; then
|
|
||||||
log_error "curl command not found"
|
|
||||||
rm -rf "$temp_dir"
|
rm -rf "$temp_dir"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test basic connectivity
|
|
||||||
log "Testing basic connectivity..."
|
|
||||||
if ! curl -s --connect-timeout 10 --max-time 30 "https://api.github.com" >/dev/null 2>&1; then
|
|
||||||
log_error "Cannot reach GitHub API"
|
|
||||||
rm -rf "$temp_dir"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
log_success "Connectivity test passed"
|
|
||||||
|
|
||||||
# Create a temporary file for curl output
|
|
||||||
local curl_log="/tmp/curl_log_$$.txt"
|
|
||||||
|
|
||||||
# Run curl with verbose output
|
|
||||||
if curl -L --connect-timeout 30 --max-time 300 --retry 3 --retry-delay 5 -v -o "$archive_file" "$download_url" > "$curl_log" 2>&1; then
|
|
||||||
log_success "Curl command completed successfully"
|
|
||||||
# Show some of the curl output for debugging
|
|
||||||
log "Curl output (first 10 lines):"
|
|
||||||
head -10 "$curl_log" | while read -r line; do
|
|
||||||
log "CURL: $line"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
local curl_exit_code=$?
|
|
||||||
log_error "Curl command failed with exit code: $curl_exit_code"
|
|
||||||
log_error "Curl output:"
|
|
||||||
cat "$curl_log" | while read -r line; do
|
|
||||||
log_error "CURL: $line"
|
|
||||||
done
|
|
||||||
rm -f "$curl_log"
|
|
||||||
rm -rf "$temp_dir"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clean up curl log
|
|
||||||
rm -f "$curl_log"
|
|
||||||
|
|
||||||
# Verify download
|
# Verify download
|
||||||
if [ ! -f "$archive_file" ] || [ ! -s "$archive_file" ]; then
|
if [ ! -f "$archive_file" ] || [ ! -s "$archive_file" ]; then
|
||||||
log_error "Downloaded file is empty or missing"
|
log_error "Downloaded file is empty or missing"
|
||||||
@@ -224,52 +255,35 @@ download_release() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local file_size
|
log_success "Downloaded release"
|
||||||
file_size=$(stat -c%s "$archive_file" 2>/dev/null || echo "0")
|
|
||||||
log_success "Downloaded release ($file_size bytes)"
|
|
||||||
|
|
||||||
# Extract release
|
# Extract release
|
||||||
log "Extracting release..."
|
if ! tar -xzf "$archive_file" -C "$temp_dir" 2>/dev/null; then
|
||||||
if ! tar -xzf "$archive_file" -C "$temp_dir"; then
|
|
||||||
log_error "Failed to extract release"
|
log_error "Failed to extract release"
|
||||||
rm -rf "$temp_dir"
|
rm -rf "$temp_dir"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Debug: List contents after extraction
|
|
||||||
log "Contents after extraction:"
|
|
||||||
ls -la "$temp_dir" >&2 || true
|
|
||||||
|
|
||||||
# Find the extracted directory (GitHub tarballs have a root directory)
|
# Find the extracted directory (GitHub tarballs have a root directory)
|
||||||
log "Looking for extracted directory with pattern: ${REPO_NAME}-*"
|
|
||||||
local extracted_dir
|
local extracted_dir
|
||||||
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "${REPO_NAME}-*" 2>/dev/null | head -1)
|
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" 2>/dev/null | head -1)
|
||||||
|
|
||||||
# If not found with repo name, try alternative patterns
|
# Try alternative patterns if not found
|
||||||
if [ -z "$extracted_dir" ]; then
|
if [ -z "$extracted_dir" ]; then
|
||||||
log "Trying pattern: community-scripts-ProxmoxVE-Local-*"
|
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -name "${REPO_NAME}-*" 2>/dev/null | head -1)
|
||||||
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" 2>/dev/null | head -1)
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$extracted_dir" ]; then
|
if [ -z "$extracted_dir" ]; then
|
||||||
log "Trying pattern: ProxmoxVE-Local-*"
|
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d ! -name "$temp_dir" 2>/dev/null | head -1)
|
||||||
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "ProxmoxVE-Local-*" 2>/dev/null | head -1)
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$extracted_dir" ]; then
|
|
||||||
log "Trying any directory in temp folder"
|
|
||||||
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d ! -name "$temp_dir" 2>/dev/null | head -1)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If still not found, error out
|
|
||||||
if [ -z "$extracted_dir" ]; then
|
if [ -z "$extracted_dir" ]; then
|
||||||
log_error "Could not find extracted directory"
|
log_error "Could not find extracted directory"
|
||||||
rm -rf "$temp_dir"
|
rm -rf "$temp_dir"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "Found extracted directory: $extracted_dir"
|
log_success "Release extracted successfully"
|
||||||
log_success "Release downloaded and extracted successfully"
|
|
||||||
echo "$extracted_dir"
|
echo "$extracted_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +291,10 @@ download_release() {
|
|||||||
clear_original_directory() {
|
clear_original_directory() {
|
||||||
log "Clearing original directory..."
|
log "Clearing original directory..."
|
||||||
|
|
||||||
|
# Remove old lock files and node_modules before update
|
||||||
|
rm -f package-lock.json 2>/dev/null
|
||||||
|
rm -rf node_modules 2>/dev/null
|
||||||
|
|
||||||
# List of files/directories to preserve (already backed up)
|
# List of files/directories to preserve (already backed up)
|
||||||
local preserve_patterns=(
|
local preserve_patterns=(
|
||||||
"data"
|
"data"
|
||||||
@@ -285,8 +303,8 @@ clear_original_directory() {
|
|||||||
"update.log"
|
"update.log"
|
||||||
"*.backup"
|
"*.backup"
|
||||||
"*.bak"
|
"*.bak"
|
||||||
"node_modules"
|
|
||||||
".git"
|
".git"
|
||||||
|
"scripts"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove all files except preserved ones
|
# Remove all files except preserved ones
|
||||||
@@ -328,7 +346,7 @@ clear_original_directory() {
|
|||||||
|
|
||||||
# Restore backup files before building
|
# Restore backup files before building
|
||||||
restore_backup_files() {
|
restore_backup_files() {
|
||||||
log "Restoring .env and data directory from backup..."
|
log "Restoring .env, data directory, and scripts directories from backup..."
|
||||||
|
|
||||||
if [ -d "$BACKUP_DIR" ]; then
|
if [ -d "$BACKUP_DIR" ]; then
|
||||||
# Restore .env file
|
# Restore .env file
|
||||||
@@ -360,6 +378,34 @@ restore_backup_files() {
|
|||||||
else
|
else
|
||||||
log_warning "No data directory backup found"
|
log_warning "No data directory backup found"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Restore scripts directories
|
||||||
|
local scripts_dirs=("ct" "install" "tools" "vm")
|
||||||
|
for backup_name in "${scripts_dirs[@]}"; do
|
||||||
|
if [ -d "$BACKUP_DIR/$backup_name" ]; then
|
||||||
|
local target_dir="scripts/$backup_name"
|
||||||
|
log "Restoring $target_dir directory from backup..."
|
||||||
|
|
||||||
|
# Ensure scripts directory exists
|
||||||
|
if [ ! -d "scripts" ]; then
|
||||||
|
mkdir -p "scripts"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove existing directory if it exists
|
||||||
|
if [ -d "$target_dir" ]; then
|
||||||
|
rm -rf "$target_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if mv "$BACKUP_DIR/$backup_name" "$target_dir"; then
|
||||||
|
log_success "$target_dir directory restored from backup"
|
||||||
|
else
|
||||||
|
log_error "Failed to restore $target_dir directory"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warning "No $backup_name directory backup found"
|
||||||
|
fi
|
||||||
|
done
|
||||||
else
|
else
|
||||||
log_error "No backup directory found for restoration"
|
log_error "No backup directory found for restoration"
|
||||||
return 1
|
return 1
|
||||||
@@ -368,148 +414,21 @@ restore_backup_files() {
|
|||||||
|
|
||||||
# Check if systemd service exists
|
# Check if systemd service exists
|
||||||
check_service() {
|
check_service() {
|
||||||
if systemctl list-unit-files | grep -q "^pvescriptslocal.service"; then
|
# systemctl status returns 0-3 if service exists (running, exited, failed, etc.)
|
||||||
|
# and returns 4 if service unit is not found
|
||||||
|
systemctl status pvescriptslocal.service &>/dev/null
|
||||||
|
local exit_code=$?
|
||||||
|
if [ $exit_code -le 3 ]; then
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Kill application processes directly
|
|
||||||
kill_processes() {
|
|
||||||
# Try to find and stop the Node.js process
|
|
||||||
local pids
|
|
||||||
pids=$(pgrep -f "node server.js" 2>/dev/null || true)
|
|
||||||
|
|
||||||
# Also check for npm start processes
|
|
||||||
local npm_pids
|
|
||||||
npm_pids=$(pgrep -f "npm start" 2>/dev/null || true)
|
|
||||||
|
|
||||||
# Combine all PIDs
|
|
||||||
if [ -n "$npm_pids" ]; then
|
|
||||||
pids="$pids $npm_pids"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$pids" ]; then
|
|
||||||
log "Stopping application processes: $pids"
|
|
||||||
|
|
||||||
# Send TERM signal to each PID individually
|
|
||||||
for pid in $pids; do
|
|
||||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
|
||||||
log "Sending TERM signal to PID: $pid"
|
|
||||||
kill -TERM "$pid" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Wait for graceful shutdown with timeout
|
|
||||||
log "Waiting for graceful shutdown..."
|
|
||||||
local wait_count=0
|
|
||||||
local max_wait=10 # Maximum 10 seconds
|
|
||||||
|
|
||||||
while [ $wait_count -lt $max_wait ]; do
|
|
||||||
local still_running
|
|
||||||
still_running=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
|
|
||||||
if [ -z "$still_running" ]; then
|
|
||||||
log_success "Processes stopped gracefully"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
wait_count=$((wait_count + 1))
|
|
||||||
log "Waiting... ($wait_count/$max_wait)"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Force kill any remaining processes
|
|
||||||
local remaining_pids
|
|
||||||
remaining_pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
|
|
||||||
if [ -n "$remaining_pids" ]; then
|
|
||||||
log_warning "Force killing remaining processes: $remaining_pids"
|
|
||||||
pkill -9 -f "node server.js" 2>/dev/null || true
|
|
||||||
pkill -9 -f "npm start" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Final check
|
|
||||||
local final_check
|
|
||||||
final_check=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
|
|
||||||
if [ -n "$final_check" ]; then
|
|
||||||
log_warning "Some processes may still be running: $final_check"
|
|
||||||
else
|
|
||||||
log_success "All application processes stopped"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "No running application processes found"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Kill application processes directly
|
|
||||||
kill_processes() {
|
|
||||||
# Try to find and stop the Node.js process
|
|
||||||
local pids
|
|
||||||
pids=$(pgrep -f "node server.js" 2>/dev/null || true)
|
|
||||||
|
|
||||||
# Also check for npm start processes
|
|
||||||
local npm_pids
|
|
||||||
npm_pids=$(pgrep -f "npm start" 2>/dev/null || true)
|
|
||||||
|
|
||||||
# Combine all PIDs
|
|
||||||
if [ -n "$npm_pids" ]; then
|
|
||||||
pids="$pids $npm_pids"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$pids" ]; then
|
|
||||||
log "Stopping application processes: $pids"
|
|
||||||
|
|
||||||
# Send TERM signal to each PID individually
|
|
||||||
for pid in $pids; do
|
|
||||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
|
||||||
log "Sending TERM signal to PID: $pid"
|
|
||||||
kill -TERM "$pid" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Wait for graceful shutdown with timeout
|
|
||||||
log "Waiting for graceful shutdown..."
|
|
||||||
local wait_count=0
|
|
||||||
local max_wait=10 # Maximum 10 seconds
|
|
||||||
|
|
||||||
while [ $wait_count -lt $max_wait ]; do
|
|
||||||
local still_running
|
|
||||||
still_running=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
|
|
||||||
if [ -z "$still_running" ]; then
|
|
||||||
log_success "Processes stopped gracefully"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
wait_count=$((wait_count + 1))
|
|
||||||
log "Waiting... ($wait_count/$max_wait)"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Force kill any remaining processes
|
|
||||||
local remaining_pids
|
|
||||||
remaining_pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
|
|
||||||
if [ -n "$remaining_pids" ]; then
|
|
||||||
log_warning "Force killing remaining processes: $remaining_pids"
|
|
||||||
pkill -9 -f "node server.js" 2>/dev/null || true
|
|
||||||
pkill -9 -f "npm start" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Final check
|
|
||||||
local final_check
|
|
||||||
final_check=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
|
|
||||||
if [ -n "$final_check" ]; then
|
|
||||||
log_warning "Some processes may still be running: $final_check"
|
|
||||||
else
|
|
||||||
log_success "All application processes stopped"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "No running application processes found"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Stop the application before updating
|
# Stop the application before updating
|
||||||
stop_application() {
|
stop_application() {
|
||||||
log "Stopping application..."
|
|
||||||
|
|
||||||
# Change to the application directory if we're not already there
|
# Change to the application directory if we're not already there
|
||||||
local app_dir
|
local app_dir
|
||||||
@@ -531,23 +450,31 @@ stop_application() {
|
|||||||
|
|
||||||
log "Working from application directory: $(pwd)"
|
log "Working from application directory: $(pwd)"
|
||||||
|
|
||||||
# Check if systemd service exists and is active
|
# Check if systemd service is running and disable it temporarily
|
||||||
if check_service; then
|
if check_service && systemctl is-active --quiet pvescriptslocal.service; then
|
||||||
if systemctl is-active --quiet pvescriptslocal.service; then
|
log "Disabling systemd service temporarily to prevent auto-restart..."
|
||||||
log "Stopping pvescriptslocal service..."
|
if systemctl disable pvescriptslocal.service; then
|
||||||
if systemctl stop pvescriptslocal.service; then
|
log_success "Service disabled successfully"
|
||||||
log_success "Service stopped successfully"
|
|
||||||
else
|
|
||||||
log_error "Failed to stop service, falling back to process kill"
|
|
||||||
kill_processes
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
log "Service exists but is not active, checking for running processes..."
|
log_error "Failed to disable service"
|
||||||
kill_processes
|
return 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log "No systemd service found, stopping processes directly..."
|
log "No running systemd service found"
|
||||||
kill_processes
|
fi
|
||||||
|
|
||||||
|
# Kill any remaining npm/node processes
|
||||||
|
log "Killing any remaining npm/node processes..."
|
||||||
|
local pids
|
||||||
|
pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
|
||||||
|
if [ -n "$pids" ]; then
|
||||||
|
log "Found running processes: $pids"
|
||||||
|
pkill -9 -f "node server.js" 2>/dev/null || true
|
||||||
|
pkill -9 -f "npm start" 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
log_success "Processes killed"
|
||||||
|
else
|
||||||
|
log "No running processes found"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,6 +494,7 @@ update_files() {
|
|||||||
"update.log"
|
"update.log"
|
||||||
"*.backup"
|
"*.backup"
|
||||||
"*.bak"
|
"*.bak"
|
||||||
|
"scripts"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Find the actual source directory (strip the top-level directory)
|
# Find the actual source directory (strip the top-level directory)
|
||||||
@@ -578,26 +506,20 @@ update_files() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Verify critical files exist in source
|
||||||
|
if [ ! -f "$actual_source_dir/package.json" ]; then
|
||||||
|
log_error "package.json not found in source directory!"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Use process substitution instead of pipe to avoid subshell issues
|
# Use process substitution instead of pipe to avoid subshell issues
|
||||||
local files_copied=0
|
local files_copied=0
|
||||||
local files_excluded=0
|
local files_excluded=0
|
||||||
|
|
||||||
log "Starting file copy process from: $actual_source_dir"
|
|
||||||
|
|
||||||
# Create a temporary file list to avoid process substitution issues
|
# Create a temporary file list to avoid process substitution issues
|
||||||
local file_list="/tmp/file_list_$$.txt"
|
local file_list="/tmp/file_list_$$.txt"
|
||||||
find "$actual_source_dir" -type f > "$file_list"
|
find "$actual_source_dir" -type f > "$file_list"
|
||||||
|
|
||||||
local total_files
|
|
||||||
total_files=$(wc -l < "$file_list")
|
|
||||||
log "Found $total_files files to process"
|
|
||||||
|
|
||||||
# Show first few files for debugging
|
|
||||||
log "First few files to process:"
|
|
||||||
head -5 "$file_list" | while read -r f; do
|
|
||||||
log " - $f"
|
|
||||||
done
|
|
||||||
|
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
local rel_path="${file#$actual_source_dir/}"
|
local rel_path="${file#$actual_source_dir/}"
|
||||||
local should_exclude=false
|
local should_exclude=false
|
||||||
@@ -615,60 +537,97 @@ update_files() {
|
|||||||
if [ "$target_dir" != "." ]; then
|
if [ "$target_dir" != "." ]; then
|
||||||
mkdir -p "$target_dir"
|
mkdir -p "$target_dir"
|
||||||
fi
|
fi
|
||||||
log "Copying: $file -> $rel_path"
|
|
||||||
if ! cp "$file" "$rel_path"; then
|
if ! cp "$file" "$rel_path"; then
|
||||||
log_error "Failed to copy $rel_path"
|
log_error "Failed to copy $rel_path"
|
||||||
rm -f "$file_list"
|
rm -f "$file_list"
|
||||||
return 1
|
return 1
|
||||||
else
|
|
||||||
files_copied=$((files_copied + 1))
|
|
||||||
if [ $((files_copied % 10)) -eq 0 ]; then
|
|
||||||
log "Copied $files_copied files so far..."
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
files_copied=$((files_copied + 1))
|
||||||
else
|
else
|
||||||
files_excluded=$((files_excluded + 1))
|
files_excluded=$((files_excluded + 1))
|
||||||
log "Excluded: $rel_path"
|
|
||||||
fi
|
fi
|
||||||
done < "$file_list"
|
done < "$file_list"
|
||||||
|
|
||||||
# Clean up temporary file
|
# Clean up temporary file
|
||||||
rm -f "$file_list"
|
rm -f "$file_list"
|
||||||
|
|
||||||
log "Files processed: $files_copied copied, $files_excluded excluded"
|
# Verify critical files were copied
|
||||||
|
if [ ! -f "package.json" ]; then
|
||||||
|
log_error "package.json was not copied to target directory!"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
log_success "Application files updated successfully"
|
if [ ! -f "package-lock.json" ]; then
|
||||||
|
log_warning "package-lock.json was not copied!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Application files updated successfully ($files_copied files)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install dependencies and build
|
# Install dependencies and build
|
||||||
install_and_build() {
|
install_and_build() {
|
||||||
log "Installing dependencies..."
|
log "Installing dependencies..."
|
||||||
|
|
||||||
if ! npm install; then
|
# Verify package.json exists
|
||||||
log_error "Failed to install dependencies"
|
if [ ! -f "package.json" ]; then
|
||||||
|
log_error "package.json not found! Cannot install dependencies."
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure no processes are running before build
|
if [ ! -f "package-lock.json" ]; then
|
||||||
log "Ensuring no conflicting processes are running..."
|
log_warning "No package-lock.json found, npm will generate one"
|
||||||
local pids
|
|
||||||
pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
|
|
||||||
if [ -n "$pids" ]; then
|
|
||||||
log_warning "Found running processes, stopping them: $pids"
|
|
||||||
pkill -9 -f "node server.js" 2>/dev/null || true
|
|
||||||
pkill -9 -f "npm start" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Create temporary file for npm output
|
||||||
|
local npm_log="/tmp/npm_install_$$.log"
|
||||||
|
|
||||||
|
# Ensure NODE_ENV is not set to production during install (we need devDependencies for build)
|
||||||
|
local old_node_env="${NODE_ENV:-}"
|
||||||
|
export NODE_ENV=development
|
||||||
|
|
||||||
|
# Run npm install to get ALL dependencies including devDependencies
|
||||||
|
if ! npm install --include=dev > "$npm_log" 2>&1; then
|
||||||
|
log_error "Failed to install dependencies"
|
||||||
|
log_error "npm install output (last 30 lines):"
|
||||||
|
tail -30 "$npm_log" | while read -r line; do
|
||||||
|
log_error "NPM: $line"
|
||||||
|
done
|
||||||
|
rm -f "$npm_log"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restore NODE_ENV
|
||||||
|
if [ -n "$old_node_env" ]; then
|
||||||
|
export NODE_ENV="$old_node_env"
|
||||||
|
else
|
||||||
|
unset NODE_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Dependencies installed successfully"
|
||||||
|
rm -f "$npm_log"
|
||||||
|
|
||||||
log "Building application..."
|
log "Building application..."
|
||||||
# Set NODE_ENV to production for build
|
# Set NODE_ENV to production for build
|
||||||
export NODE_ENV=production
|
export NODE_ENV=production
|
||||||
|
|
||||||
if ! npm run build; then
|
# Create temporary file for npm build output
|
||||||
|
local build_log="/tmp/npm_build_$$.log"
|
||||||
|
|
||||||
|
if ! npm run build > "$build_log" 2>&1; then
|
||||||
log_error "Failed to build application"
|
log_error "Failed to build application"
|
||||||
|
log_error "npm run build output:"
|
||||||
|
cat "$build_log" | while read -r line; do
|
||||||
|
log_error "BUILD: $line"
|
||||||
|
done
|
||||||
|
rm -f "$build_log"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Log success and clean up
|
||||||
|
log_success "Application built successfully"
|
||||||
|
rm -f "$build_log"
|
||||||
|
|
||||||
log_success "Dependencies installed and application built successfully"
|
log_success "Dependencies installed and application built successfully"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,11 +635,12 @@ install_and_build() {
|
|||||||
start_application() {
|
start_application() {
|
||||||
log "Starting application..."
|
log "Starting application..."
|
||||||
|
|
||||||
# Check if systemd service exists
|
# Use the global variable to determine how to start
|
||||||
if check_service; then
|
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
|
||||||
log "Starting pvescriptslocal service..."
|
log "Service was running before update, re-enabling and starting systemd service..."
|
||||||
if systemctl start pvescriptslocal.service; then
|
if systemctl enable --now pvescriptslocal.service; then
|
||||||
log_success "Service started successfully"
|
systemctl restart pvescriptslocal.service
|
||||||
|
log_success "Service enabled and started successfully"
|
||||||
# Wait a moment and check if it's running
|
# Wait a moment and check if it's running
|
||||||
sleep 2
|
sleep 2
|
||||||
if systemctl is-active --quiet pvescriptslocal.service; then
|
if systemctl is-active --quiet pvescriptslocal.service; then
|
||||||
@@ -689,11 +649,11 @@ start_application() {
|
|||||||
log_warning "Service started but may not be running properly"
|
log_warning "Service started but may not be running properly"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log_error "Failed to start service, falling back to npm start"
|
log_error "Failed to enable/start service, falling back to npm start"
|
||||||
start_with_npm
|
start_with_npm
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log "No systemd service found, starting with npm..."
|
log "Service was not running before update or no service exists, starting with npm..."
|
||||||
start_with_npm
|
start_with_npm
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -753,6 +713,33 @@ rollback() {
|
|||||||
log_warning "No .env file backup found"
|
log_warning "No .env file backup found"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Restore scripts directories
|
||||||
|
local scripts_dirs=("ct" "install" "tools" "vm")
|
||||||
|
for backup_name in "${scripts_dirs[@]}"; do
|
||||||
|
if [ -d "$BACKUP_DIR/$backup_name" ]; then
|
||||||
|
local target_dir="scripts/$backup_name"
|
||||||
|
log "Restoring $target_dir directory from backup..."
|
||||||
|
|
||||||
|
# Ensure scripts directory exists
|
||||||
|
if [ ! -d "scripts" ]; then
|
||||||
|
mkdir -p "scripts"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove existing directory if it exists
|
||||||
|
if [ -d "$target_dir" ]; then
|
||||||
|
rm -rf "$target_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if mv "$BACKUP_DIR/$backup_name" "$target_dir"; then
|
||||||
|
log_success "$target_dir directory restored from backup"
|
||||||
|
else
|
||||||
|
log_error "Failed to restore $target_dir directory"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warning "No $backup_name directory backup found"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Clean up backup directory
|
# Clean up backup directory
|
||||||
log "Cleaning up backup directory..."
|
log "Cleaning up backup directory..."
|
||||||
rm -rf "$BACKUP_DIR"
|
rm -rf "$BACKUP_DIR"
|
||||||
@@ -766,25 +753,22 @@ rollback() {
|
|||||||
|
|
||||||
# Main update process
|
# Main update process
|
||||||
main() {
|
main() {
|
||||||
init_log
|
# Check if this is the relocated/detached version first
|
||||||
|
if [ "${1:-}" = "--relocated" ]; then
|
||||||
|
export PVE_UPDATE_RELOCATED=1
|
||||||
|
init_log
|
||||||
|
log "Running as detached process"
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
else
|
||||||
|
init_log
|
||||||
|
fi
|
||||||
|
|
||||||
# Check if we're running from the application directory and not already relocated
|
# Check if we're running from the application directory and not already relocated
|
||||||
if [ -z "${PVE_UPDATE_RELOCATED:-}" ] && [ -f "package.json" ] && [ -f "server.js" ]; then
|
if [ -z "${PVE_UPDATE_RELOCATED:-}" ] && [ -f "package.json" ] && [ -f "server.js" ]; then
|
||||||
log "Detected running from application directory"
|
log "Detected running from application directory"
|
||||||
log "Copying update script to temporary location for safe execution..."
|
bash "$0" --relocated
|
||||||
|
exit $?
|
||||||
local temp_script="/tmp/pve-scripts-update-$$.sh"
|
|
||||||
if ! cp "$0" "$temp_script"; then
|
|
||||||
log_error "Failed to copy update script to temporary location"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
chmod +x "$temp_script"
|
|
||||||
log "Executing update from temporary location: $temp_script"
|
|
||||||
|
|
||||||
# Set flag to prevent infinite loop and execute from temporary location
|
|
||||||
export PVE_UPDATE_RELOCATED=1
|
|
||||||
exec "$temp_script" "$@"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure we're in the application directory
|
# Ensure we're in the application directory
|
||||||
@@ -793,9 +777,8 @@ main() {
|
|||||||
# First check if we're already in the right directory
|
# First check if we're already in the right directory
|
||||||
if [ -f "package.json" ] && [ -f "server.js" ]; then
|
if [ -f "package.json" ] && [ -f "server.js" ]; then
|
||||||
app_dir="$(pwd)"
|
app_dir="$(pwd)"
|
||||||
log "Already in application directory: $app_dir"
|
|
||||||
else
|
else
|
||||||
# Try multiple common locations
|
# Try multiple common locations:
|
||||||
for search_path in /opt /root /home /usr/local; do
|
for search_path in /opt /root /home /usr/local; do
|
||||||
if [ -d "$search_path" ]; then
|
if [ -d "$search_path" ]; then
|
||||||
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
|
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
|
||||||
@@ -810,10 +793,8 @@ main() {
|
|||||||
log_error "Failed to change to application directory: $app_dir"
|
log_error "Failed to change to application directory: $app_dir"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
log "Changed to application directory: $(pwd)"
|
|
||||||
else
|
else
|
||||||
log_error "Could not find application directory"
|
log_error "Could not find application directory"
|
||||||
log "Searched in: /opt, /root, /home, /usr/local"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -821,6 +802,16 @@ main() {
|
|||||||
# Check dependencies
|
# Check dependencies
|
||||||
check_dependencies
|
check_dependencies
|
||||||
|
|
||||||
|
# Load GitHub token for higher rate limits
|
||||||
|
load_github_token
|
||||||
|
|
||||||
|
# Check if service was running before update
|
||||||
|
if check_service && systemctl is-active --quiet pvescriptslocal.service; then
|
||||||
|
SERVICE_WAS_RUNNING=true
|
||||||
|
else
|
||||||
|
SERVICE_WAS_RUNNING=false
|
||||||
|
fi
|
||||||
|
|
||||||
# Get latest release info
|
# Get latest release info
|
||||||
local release_info
|
local release_info
|
||||||
release_info=$(get_latest_release)
|
release_info=$(get_latest_release)
|
||||||
@@ -828,60 +819,35 @@ main() {
|
|||||||
# Backup data directory
|
# Backup data directory
|
||||||
backup_data
|
backup_data
|
||||||
|
|
||||||
# Stop the application before updating (now running from /tmp/)
|
# Stop the application before updating
|
||||||
stop_application
|
stop_application
|
||||||
|
|
||||||
# Double-check that no processes are running
|
|
||||||
local remaining_pids
|
|
||||||
remaining_pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
|
|
||||||
if [ -n "$remaining_pids" ]; then
|
|
||||||
log_warning "Force killing remaining processes"
|
|
||||||
pkill -9 -f "node server.js" 2>/dev/null || true
|
|
||||||
pkill -9 -f "npm start" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download and extract release
|
# Download and extract release
|
||||||
local source_dir
|
local source_dir
|
||||||
source_dir=$(download_release "$release_info")
|
source_dir=$(download_release "$release_info")
|
||||||
log "Download completed, source_dir: $source_dir"
|
|
||||||
|
|
||||||
# Clear the original directory before updating
|
# Clear the original directory before updating
|
||||||
log "Clearing original directory..."
|
|
||||||
clear_original_directory
|
clear_original_directory
|
||||||
log "Original directory cleared successfully"
|
|
||||||
|
|
||||||
# Update files
|
# Update files
|
||||||
log "Starting file update process..."
|
|
||||||
if ! update_files "$source_dir"; then
|
if ! update_files "$source_dir"; then
|
||||||
log_error "File update failed, rolling back..."
|
log_error "File update failed, rolling back..."
|
||||||
rollback
|
rollback
|
||||||
fi
|
fi
|
||||||
log "File update completed successfully"
|
|
||||||
|
|
||||||
# Restore .env and data directory before building
|
# Restore .env and data directory before building
|
||||||
log "Restoring backup files..."
|
|
||||||
restore_backup_files
|
restore_backup_files
|
||||||
log "Backup files restored successfully"
|
|
||||||
|
|
||||||
# Install dependencies and build
|
# Install dependencies and build
|
||||||
log "Starting install and build process..."
|
|
||||||
if ! install_and_build; then
|
if ! install_and_build; then
|
||||||
log_error "Install and build failed, rolling back..."
|
log_error "Install and build failed, rolling back..."
|
||||||
rollback
|
rollback
|
||||||
fi
|
fi
|
||||||
log "Install and build completed successfully"
|
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
log "Cleaning up temporary files..."
|
|
||||||
rm -rf "$source_dir"
|
rm -rf "$source_dir"
|
||||||
rm -rf "/tmp/pve-update-$$"
|
rm -rf "/tmp/pve-update-$$"
|
||||||
|
|
||||||
# Clean up temporary script if it exists
|
|
||||||
if [ -f "/tmp/pve-scripts-update-$$.sh" ]; then
|
|
||||||
rm -f "/tmp/pve-scripts-update-$$.sh"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
start_application
|
start_application
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user