Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c855d1c864 | ||
|
|
4af5ad4f7b | ||
|
|
537d65275a | ||
|
|
ef460b5a00 | ||
|
|
87ab645231 | ||
|
|
9c44a47b3d | ||
|
|
b793c57000 | ||
|
|
6b45c41334 | ||
|
|
a8eb41e087 | ||
|
|
52adbd9f5c | ||
|
|
73d3aeec99 | ||
|
|
1635bb17da | ||
|
|
b4b8da5725 | ||
|
|
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 | ||
|
|
6c2868f8b9 | ||
|
|
c2705430a3 | ||
|
|
fc4c6efa8c | ||
|
|
8039d5aa96 | ||
|
|
b670c4e3c8 | ||
|
|
3e90369682 | ||
|
|
24430ee77d | ||
|
|
0b1ce29b64 | ||
|
|
c7af2eb1a8 | ||
|
|
7ff4d56753 | ||
|
|
b2ae96dcd0 | ||
|
|
3530d78c78 | ||
|
|
a3f062a77f | ||
|
|
bcdae46867 | ||
|
|
f055be1f4a | ||
|
|
7e91c598ae | ||
|
|
123977d0a3 | ||
|
|
35cc000a2a | ||
|
|
d71e8dd96a | ||
|
|
67b63019ab | ||
|
|
ff076a5a40 | ||
|
|
a7479091dc | ||
|
|
ff9a875561 | ||
|
|
6bcf139493 | ||
|
|
eb8801bfc8 | ||
|
|
91cdc557a1 | ||
|
|
6a7a1f94f9 | ||
|
|
b96b5493f3 | ||
|
|
6baef6bb84 | ||
|
|
05d88eb8c8 | ||
|
|
03d871eca8 | ||
|
|
b366a33f07 |
11
.env.example
11
.env.example
@@ -16,3 +16,14 @@ 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=
|
||||||
|
DATABASE_URL="file:./data/database.sqlite"
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
9
.github/release-drafter.yml
vendored
9
.github/release-drafter.yml
vendored
@@ -1,7 +1,15 @@
|
|||||||
# Template for release drafts
|
# Template for release drafts
|
||||||
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
||||||
tag-template: 'v$NEXT_PATCH_VERSION'
|
tag-template: 'v$NEXT_PATCH_VERSION'
|
||||||
|
|
||||||
|
# Exclude PRs with this label from release notes
|
||||||
|
exclude-labels:
|
||||||
|
- automated
|
||||||
|
|
||||||
categories:
|
categories:
|
||||||
|
- title: "Breaking Changes"
|
||||||
|
labels:
|
||||||
|
- breaking
|
||||||
- title: "🚀 Features"
|
- title: "🚀 Features"
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
@@ -17,6 +25,7 @@ categories:
|
|||||||
labels:
|
labels:
|
||||||
- dependencies
|
- dependencies
|
||||||
- javascript
|
- javascript
|
||||||
|
|
||||||
change-template: '- $TITLE (#$NUMBER) by @$AUTHOR'
|
change-template: '- $TITLE (#$NUMBER) by @$AUTHOR'
|
||||||
change-title-template: '### $TITLE'
|
change-title-template: '### $TITLE'
|
||||||
template: |
|
template: |
|
||||||
|
|||||||
119
.github/workflows/publish_release.yml
vendored
Normal file
119
.github/workflows/publish_release.yml
vendored
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
name: Publish draft release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: # Manual trigger; can be automated later
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get latest draft release
|
||||||
|
id: draft
|
||||||
|
run: |
|
||||||
|
draft_info=$(gh release list --limit 5 --json tagName,isDraft --jq '.[] | select(.isDraft==true) | .tagName' | head -n1)
|
||||||
|
echo "tag_name=${draft_info}" >> $GITHUB_OUTPUT
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Validate draft version
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ steps.draft.outputs.tag_name }}" ]; then
|
||||||
|
echo "No draft release found!" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found draft version: ${{ steps.draft.outputs.tag_name }}"
|
||||||
|
|
||||||
|
|
||||||
|
- name: Create branch and commit VERSION
|
||||||
|
run: |
|
||||||
|
branch="update-version-${{ steps.draft.outputs.tag_name }}"
|
||||||
|
# Delete remote branch if exists
|
||||||
|
git push origin --delete "$branch" || echo "No remote branch to delete"
|
||||||
|
git fetch origin main
|
||||||
|
git checkout -b "$branch" origin/main
|
||||||
|
# Write VERSION file and timestamp to ensure a diff
|
||||||
|
version="${{ steps.draft.outputs.tag_name }}"
|
||||||
|
echo "$version" | sed 's/^v//' > VERSION
|
||||||
|
git add VERSION
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git commit -m "chore: add VERSION $version" --allow-empty
|
||||||
|
git push --set-upstream origin "$branch"
|
||||||
|
|
||||||
|
|
||||||
|
- name: Create PR with GitHub CLI
|
||||||
|
id: pr
|
||||||
|
run: |
|
||||||
|
pr_url=$(gh pr create \
|
||||||
|
--base main \
|
||||||
|
--head update-version-${{ steps.draft.outputs.tag_name }} \
|
||||||
|
--title "chore: add VERSION ${{ steps.draft.outputs.tag_name }}" \
|
||||||
|
--body "Adds VERSION file for release ${{ steps.draft.outputs.tag_name }}" \
|
||||||
|
--label automated)
|
||||||
|
|
||||||
|
pr_number=$(echo "$pr_url" | awk -F/ '{print $NF}')
|
||||||
|
echo $pr_number
|
||||||
|
echo "pr_number=$pr_number" >> $GITHUB_OUTPUT
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
|
# - name: Approve pull request
|
||||||
|
# env:
|
||||||
|
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# run: |
|
||||||
|
# PR_NUMBER="${{ steps.pr.outputs.pr_number }}"
|
||||||
|
# if [ -n "$PR_NUMBER" ]; then
|
||||||
|
# gh pr review $PR_NUMBER --approve
|
||||||
|
# fi
|
||||||
|
|
||||||
|
- name: Merge PR
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git config --global user.name "github-actions-automege[bot]"
|
||||||
|
git config --global user.email "github-actions-automege[bot]@users.noreply.github.com"
|
||||||
|
PR_NUMBER="${{ steps.pr.outputs.pr_number }}"
|
||||||
|
if [ -n "$PR_NUMBER" ]; then
|
||||||
|
gh pr merge "$PR_NUMBER" --squash --admin
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Wait for PR merge
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const prNum = parseInt("${{ steps.pr.outputs.pr_number }}")
|
||||||
|
let merged = false
|
||||||
|
const maxRetries = 20
|
||||||
|
let tries = 0
|
||||||
|
while(!merged && tries < maxRetries){
|
||||||
|
const pr = await github.rest.pulls.get({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: prNum
|
||||||
|
})
|
||||||
|
merged = pr.data.merged
|
||||||
|
if(!merged){
|
||||||
|
tries++
|
||||||
|
console.log("Waiting for PR to merge...")
|
||||||
|
await new Promise(r => setTimeout(r, 5000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!merged) throw new Error("PR not merged in time")
|
||||||
|
|
||||||
|
- name: Create tag
|
||||||
|
run: |
|
||||||
|
git tag "${{ steps.draft.outputs.tag_name }}"
|
||||||
|
git push origin "${{ steps.draft.outputs.tag_name }}"
|
||||||
|
|
||||||
|
- name: Publish draft release
|
||||||
|
run: gh release edit "${{ steps.draft.outputs.tag_name }}" --draft=false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
4
.gitignore
vendored
4
.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/
|
||||||
@@ -47,3 +50,4 @@ yarn-error.log*
|
|||||||
|
|
||||||
# idea files
|
# idea files
|
||||||
.idea
|
.idea
|
||||||
|
/generated/prisma
|
||||||
|
|||||||
248
README.md
248
README.md
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming. No more need for curl -> bash calls, it all happens in your enviroment.
|
A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming. No more need for curl -> bash calls, it all happens in your enviroment.
|
||||||
|
|
||||||
|
|
||||||
|
<img width="1725" height="1088" alt="image" src="https://github.com/user-attachments/assets/75323765-7375-4346-a41e-08d219275248" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 🎯 Deployment Options
|
## 🎯 Deployment Options
|
||||||
|
|
||||||
This application can be deployed in multiple ways to suit different environments:
|
This application can be deployed in multiple ways to suit different environments:
|
||||||
@@ -205,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;
|
||||||
|
|||||||
4673
package-lock.json
generated
4673
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -22,51 +22,65 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.17.1",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@t3-oss/env-nextjs": "^0.13.8",
|
"@t3-oss/env-nextjs": "^0.13.8",
|
||||||
"@tanstack/react-query": "^5.87.4",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@trpc/client": "^11.0.0",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@trpc/react-query": "^11.0.0",
|
"@trpc/client": "^11.6.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/react-query": "^11.6.0",
|
||||||
|
"@trpc/server": "^11.6.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"better-sqlite3": "^9.6.0",
|
"bcryptjs": "^3.0.2",
|
||||||
"next": "^15.5.3",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"next": "^15.5.5",
|
||||||
"node-pty": "^1.0.0",
|
"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",
|
||||||
"ws": "^8.18.3",
|
"ws": "^8.18.3",
|
||||||
"zod": "^3.24.2"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "^4.0.15",
|
"@tailwindcss/postcss": "^4.0.15",
|
||||||
"@testing-library/jest-dom": "^6.8.0",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/bcryptjs": "^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.8.0",
|
||||||
"@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.2.3",
|
"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.0.15",
|
"prisma": "^6.17.1",
|
||||||
|
"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": {
|
||||||
|
|||||||
74
prisma/migrations/20251017092130_init/migration.sql
Normal file
74
prisma/migrations/20251017092130_init/migration.sql
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "installed_scripts" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"script_name" TEXT NOT NULL,
|
||||||
|
"script_path" TEXT NOT NULL,
|
||||||
|
"container_id" TEXT,
|
||||||
|
"server_id" INTEGER,
|
||||||
|
"execution_mode" TEXT NOT NULL,
|
||||||
|
"installation_date" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"output_log" TEXT,
|
||||||
|
"web_ui_ip" TEXT,
|
||||||
|
"web_ui_port" INTEGER,
|
||||||
|
CONSTRAINT "installed_scripts_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "servers" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "servers" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"ip" TEXT NOT NULL,
|
||||||
|
"user" TEXT NOT NULL,
|
||||||
|
"password" TEXT,
|
||||||
|
"auth_type" TEXT DEFAULT 'password',
|
||||||
|
"ssh_key" TEXT,
|
||||||
|
"ssh_key_passphrase" TEXT,
|
||||||
|
"ssh_port" INTEGER DEFAULT 22,
|
||||||
|
"color" TEXT,
|
||||||
|
"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME,
|
||||||
|
"ssh_key_path" TEXT,
|
||||||
|
"key_generated" BOOLEAN DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "lxc_configs" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"installed_script_id" INTEGER NOT NULL,
|
||||||
|
"arch" TEXT,
|
||||||
|
"cores" INTEGER,
|
||||||
|
"memory" INTEGER,
|
||||||
|
"hostname" TEXT,
|
||||||
|
"swap" INTEGER,
|
||||||
|
"onboot" INTEGER,
|
||||||
|
"ostype" TEXT,
|
||||||
|
"unprivileged" INTEGER,
|
||||||
|
"net_name" TEXT,
|
||||||
|
"net_bridge" TEXT,
|
||||||
|
"net_hwaddr" TEXT,
|
||||||
|
"net_ip_type" TEXT,
|
||||||
|
"net_ip" TEXT,
|
||||||
|
"net_gateway" TEXT,
|
||||||
|
"net_type" TEXT,
|
||||||
|
"net_vlan" INTEGER,
|
||||||
|
"rootfs_storage" TEXT,
|
||||||
|
"rootfs_size" TEXT,
|
||||||
|
"feature_keyctl" INTEGER,
|
||||||
|
"feature_nesting" INTEGER,
|
||||||
|
"feature_fuse" INTEGER,
|
||||||
|
"feature_mount" TEXT,
|
||||||
|
"tags" TEXT,
|
||||||
|
"advanced_config" TEXT,
|
||||||
|
"synced_at" DATETIME,
|
||||||
|
"config_hash" TEXT,
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "lxc_configs_installed_script_id_fkey" FOREIGN KEY ("installed_script_id") REFERENCES "installed_scripts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "servers_name_key" ON "servers"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "lxc_configs_installed_script_id_key" ON "lxc_configs"("installed_script_id");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
97
prisma/schema.prisma
Normal file
97
prisma/schema.prisma
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model InstalledScript {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
script_name String
|
||||||
|
script_path String
|
||||||
|
container_id String?
|
||||||
|
server_id Int?
|
||||||
|
execution_mode String
|
||||||
|
installation_date DateTime? @default(now())
|
||||||
|
status String
|
||||||
|
output_log String?
|
||||||
|
web_ui_ip String?
|
||||||
|
web_ui_port Int?
|
||||||
|
server Server? @relation(fields: [server_id], references: [id], onDelete: SetNull)
|
||||||
|
lxc_config LXCConfig?
|
||||||
|
|
||||||
|
@@map("installed_scripts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Server {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
ip String
|
||||||
|
user String
|
||||||
|
password String?
|
||||||
|
auth_type String? @default("password")
|
||||||
|
ssh_key String?
|
||||||
|
ssh_key_passphrase String?
|
||||||
|
ssh_port Int? @default(22)
|
||||||
|
color String?
|
||||||
|
created_at DateTime? @default(now())
|
||||||
|
updated_at DateTime? @updatedAt
|
||||||
|
ssh_key_path String?
|
||||||
|
key_generated Boolean? @default(false)
|
||||||
|
installed_scripts InstalledScript[]
|
||||||
|
|
||||||
|
@@map("servers")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LXCConfig {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
installed_script_id Int @unique
|
||||||
|
installed_script InstalledScript @relation(fields: [installed_script_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Basic settings
|
||||||
|
arch String?
|
||||||
|
cores Int?
|
||||||
|
memory Int?
|
||||||
|
hostname String?
|
||||||
|
swap Int?
|
||||||
|
onboot Int? // 0 or 1
|
||||||
|
ostype String?
|
||||||
|
unprivileged Int? // 0 or 1
|
||||||
|
|
||||||
|
// Network settings (net0)
|
||||||
|
net_name String?
|
||||||
|
net_bridge String?
|
||||||
|
net_hwaddr String?
|
||||||
|
net_ip_type String? // 'dhcp' or 'static'
|
||||||
|
net_ip String? // IP with CIDR for static
|
||||||
|
net_gateway String?
|
||||||
|
net_type String? // usually 'veth'
|
||||||
|
net_vlan Int?
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
rootfs_storage String?
|
||||||
|
rootfs_size String?
|
||||||
|
|
||||||
|
// Features
|
||||||
|
feature_keyctl Int? // 0 or 1
|
||||||
|
feature_nesting Int? // 0 or 1
|
||||||
|
feature_fuse Int? // 0 or 1
|
||||||
|
feature_mount String? // other mount features
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
tags String?
|
||||||
|
|
||||||
|
// Advanced/raw settings (lxc.* entries and other uncommon settings)
|
||||||
|
advanced_config String? // Text blob for advanced settings
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
synced_at DateTime?
|
||||||
|
config_hash String? // Hash of server config for diff detection
|
||||||
|
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("lxc_configs")
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
@@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
SCRIPT_DIR="$(dirname "$0")"
|
|
||||||
source "$SCRIPT_DIR/../core/build.func"
|
|
||||||
# Copyright (c) 2021-2025 tteck
|
|
||||||
# Author: tteck (tteckster)
|
|
||||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
|
||||||
# Source: https://www.debian.org/
|
|
||||||
|
|
||||||
APP="Debian"
|
|
||||||
var_tags="${var_tags:-os}"
|
|
||||||
var_cpu="${var_cpu:-1}"
|
|
||||||
var_ram="${var_ram:-512}"
|
|
||||||
var_disk="${var_disk:-2}"
|
|
||||||
var_os="${var_os:-debian}"
|
|
||||||
var_version="${var_version:-13}"
|
|
||||||
var_unprivileged="${var_unprivileged:-1}"
|
|
||||||
|
|
||||||
header_info "$APP"
|
|
||||||
variables
|
|
||||||
color
|
|
||||||
catch_errors
|
|
||||||
|
|
||||||
function update_script() {
|
|
||||||
header_info
|
|
||||||
check_container_storage
|
|
||||||
check_container_resources
|
|
||||||
if [[ ! -d /var ]]; then
|
|
||||||
msg_error "No ${APP} Installation Found!"
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
msg_info "Updating $APP LXC"
|
|
||||||
$STD apt update
|
|
||||||
$STD apt -y upgrade
|
|
||||||
msg_ok "Updated $APP LXC"
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
start
|
|
||||||
build_container
|
|
||||||
description
|
|
||||||
|
|
||||||
msg_ok "Completed Successfully!\n"
|
|
||||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# Copyright (c) 2021-2025 tteck
|
|
||||||
# Author: tteck (tteckster)
|
|
||||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
|
||||||
# Source: https://www.debian.org/
|
|
||||||
|
|
||||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
|
||||||
color
|
|
||||||
verb_ip6
|
|
||||||
catch_errors
|
|
||||||
setting_up_container
|
|
||||||
network_check
|
|
||||||
update_os
|
|
||||||
|
|
||||||
motd_ssh
|
|
||||||
customize
|
|
||||||
|
|
||||||
msg_info "Cleaning up"
|
|
||||||
$STD apt -y autoremove
|
|
||||||
$STD apt -y autoclean
|
|
||||||
$STD apt -y clean
|
|
||||||
msg_ok "Cleaned"
|
|
||||||
|
|
||||||
@@ -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": []
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,84 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"name": "sassanix/Warracker",
|
||||||
|
"version": "0.10.1.14",
|
||||||
|
"date": "2025-10-06T23:35:16Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "outline/outline",
|
||||||
|
"version": "v1.0.0-1",
|
||||||
|
"date": "2025-10-06T23:16:32Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ombi-app/Ombi",
|
||||||
|
"version": "v4.47.1",
|
||||||
|
"date": "2025-01-05T21:14:23Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kometa-Team/Kometa",
|
||||||
|
"version": "v2.2.2",
|
||||||
|
"date": "2025-10-06T21:31:07Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "booklore-app/booklore",
|
||||||
|
"version": "v1.5.0",
|
||||||
|
"date": "2025-10-06T20:56:57Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "grokability/snipe-it",
|
||||||
|
"version": "v8.3.3",
|
||||||
|
"date": "2025-10-06T19:57:17Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "meilisearch/meilisearch",
|
||||||
|
"version": "prototype-shorten-snapshot-creation-2",
|
||||||
|
"date": "2025-10-06T19:36:54Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TwiN/gatus",
|
||||||
|
"version": "v5.26.0",
|
||||||
|
"date": "2025-10-06T17:57:27Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "seerr-team/seerr",
|
||||||
|
"version": "preview-seerr",
|
||||||
|
"date": "2025-10-06T16:50:29Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "zwave-js/zwave-js-ui",
|
||||||
|
"version": "v11.4.0",
|
||||||
|
"date": "2025-10-06T16:08:51Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fuma-nama/fumadocs",
|
||||||
|
"version": "fumadocs-ui@15.8.4",
|
||||||
|
"date": "2025-10-06T15:41:49Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bunkerity/bunkerweb",
|
||||||
|
"version": "v1.6.5",
|
||||||
|
"date": "2025-10-06T15:25:17Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bastienwirtz/homer",
|
||||||
|
"version": "v25.10.1",
|
||||||
|
"date": "2025-10-06T14:23:20Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "chrisvel/tududi",
|
||||||
|
"version": "v0.83",
|
||||||
|
"date": "2025-10-06T13:49:52Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dgtlmoon/changedetection.io",
|
||||||
|
"version": "0.50.16",
|
||||||
|
"date": "2025-10-06T13:40:13Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "n8n-io/n8n",
|
||||||
|
"version": "n8n@1.114.3",
|
||||||
|
"date": "2025-10-06T12:22:22Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Graylog2/graylog2-server",
|
"name": "Graylog2/graylog2-server",
|
||||||
"version": "7.0.0-beta.3",
|
"version": "7.0.0-beta.3",
|
||||||
@@ -29,11 +109,6 @@
|
|||||||
"version": "v0.24.82",
|
"version": "v0.24.82",
|
||||||
"date": "2025-10-06T07:56:13Z"
|
"date": "2025-10-06T07:56:13Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "dgtlmoon/changedetection.io",
|
|
||||||
"version": "0.50.15",
|
|
||||||
"date": "2025-10-06T07:15:01Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "firefly-iii/firefly-iii",
|
"name": "firefly-iii/firefly-iii",
|
||||||
"version": "v6.4.0",
|
"version": "v6.4.0",
|
||||||
@@ -74,11 +149,6 @@
|
|||||||
"version": "4.5.3",
|
"version": "4.5.3",
|
||||||
"date": "2025-08-25T13:59:56Z"
|
"date": "2025-08-25T13:59:56Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "outline/outline",
|
|
||||||
"version": "v1.0.0-0",
|
|
||||||
"date": "2025-10-05T20:30:31Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "plankanban/planka",
|
"name": "plankanban/planka",
|
||||||
"version": "planka-1.0.5",
|
"version": "planka-1.0.5",
|
||||||
@@ -101,19 +171,14 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "runtipi/runtipi",
|
"name": "runtipi/runtipi",
|
||||||
"version": "v4.4.0",
|
"version": "nightly",
|
||||||
"date": "2025-09-02T19:26:18Z"
|
"date": "2025-10-05T14:13:25Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Prowlarr/Prowlarr",
|
"name": "Prowlarr/Prowlarr",
|
||||||
"version": "v2.0.5.5160",
|
"version": "v2.0.5.5160",
|
||||||
"date": "2025-08-23T21:23:11Z"
|
"date": "2025-08-23T21:23:11Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "chrisvel/tududi",
|
|
||||||
"version": "v0.82-rc5",
|
|
||||||
"date": "2025-09-23T07:31:12Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "TandoorRecipes/recipes",
|
"name": "TandoorRecipes/recipes",
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
@@ -159,11 +224,6 @@
|
|||||||
"version": "2.520",
|
"version": "2.520",
|
||||||
"date": "2025-10-05T00:51:34Z"
|
"date": "2025-10-05T00:51:34Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Ombi-app/Ombi",
|
|
||||||
"version": "v4.47.1",
|
|
||||||
"date": "2025-01-05T21:14:23Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "ollama/ollama",
|
"name": "ollama/ollama",
|
||||||
"version": "v0.12.4-rc5",
|
"version": "v0.12.4-rc5",
|
||||||
@@ -224,16 +284,6 @@
|
|||||||
"version": "2025.10.1",
|
"version": "2025.10.1",
|
||||||
"date": "2025-10-03T18:10:59Z"
|
"date": "2025-10-03T18:10:59Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "fuma-nama/fumadocs",
|
|
||||||
"version": "@fumadocs/mdx-remote@1.4.2",
|
|
||||||
"date": "2025-10-03T17:01:32Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "bunkerity/bunkerweb",
|
|
||||||
"version": "v1.6.5",
|
|
||||||
"date": "2025-10-03T16:43:34Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "immich-app/immich",
|
"name": "immich-app/immich",
|
||||||
"version": "v2.0.1",
|
"version": "v2.0.1",
|
||||||
@@ -259,11 +309,6 @@
|
|||||||
"version": "v0.30.1",
|
"version": "v0.30.1",
|
||||||
"date": "2025-10-03T06:55:25Z"
|
"date": "2025-10-03T06:55:25Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "booklore-app/booklore",
|
|
||||||
"version": "v1.4.1",
|
|
||||||
"date": "2025-10-03T06:52:35Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "redis/redis",
|
"name": "redis/redis",
|
||||||
"version": "8.2.2",
|
"version": "8.2.2",
|
||||||
@@ -279,16 +324,6 @@
|
|||||||
"version": "v0.9.95",
|
"version": "v0.9.95",
|
||||||
"date": "2025-10-02T16:07:18Z"
|
"date": "2025-10-02T16:07:18Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "meilisearch/meilisearch",
|
|
||||||
"version": "prototype-shorten-snapshot-creation-0",
|
|
||||||
"date": "2025-10-02T15:16:05Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "n8n-io/n8n",
|
|
||||||
"version": "n8n@1.112.6",
|
|
||||||
"date": "2025-09-26T10:56:27Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "theonedev/onedev",
|
"name": "theonedev/onedev",
|
||||||
"version": "v13.0.7",
|
"version": "v13.0.7",
|
||||||
@@ -389,11 +424,6 @@
|
|||||||
"version": "v4.4.2",
|
"version": "v4.4.2",
|
||||||
"date": "2025-09-30T20:16:13Z"
|
"date": "2025-09-30T20:16:13Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "TwiN/gatus",
|
|
||||||
"version": "v5.25.2",
|
|
||||||
"date": "2025-09-30T18:32:35Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "WordPress/WordPress",
|
"name": "WordPress/WordPress",
|
||||||
"version": "4.7.31",
|
"version": "4.7.31",
|
||||||
@@ -414,11 +444,6 @@
|
|||||||
"version": "4.4.46",
|
"version": "4.4.46",
|
||||||
"date": "2025-09-30T13:21:24Z"
|
"date": "2025-09-30T13:21:24Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "fallenbagel/jellyseerr",
|
|
||||||
"version": "preview-rename-tags",
|
|
||||||
"date": "2025-09-30T12:50:15Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "emqx/emqx",
|
"name": "emqx/emqx",
|
||||||
"version": "e6.0.0",
|
"version": "e6.0.0",
|
||||||
@@ -459,11 +484,6 @@
|
|||||||
"version": "v2.7.12",
|
"version": "v2.7.12",
|
||||||
"date": "2025-05-29T17:08:26Z"
|
"date": "2025-05-29T17:08:26Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "sassanix/Warracker",
|
|
||||||
"version": "0.10.1.13",
|
|
||||||
"date": "2025-09-29T17:11:25Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "AdguardTeam/AdGuardHome",
|
"name": "AdguardTeam/AdGuardHome",
|
||||||
"version": "v0.107.67",
|
"version": "v0.107.67",
|
||||||
@@ -536,8 +556,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "javedh-dev/tracktor",
|
"name": "javedh-dev/tracktor",
|
||||||
"version": "0.3.17",
|
"version": "0.3.18",
|
||||||
"date": "2025-09-27T07:00:36Z"
|
"date": "2025-09-27T10:32:09Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dolibarr/dolibarr",
|
"name": "Dolibarr/dolibarr",
|
||||||
@@ -554,11 +574,6 @@
|
|||||||
"version": "v4.104.2",
|
"version": "v4.104.2",
|
||||||
"date": "2025-09-26T22:34:32Z"
|
"date": "2025-09-26T22:34:32Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "bastienwirtz/homer",
|
|
||||||
"version": "v25.09.1",
|
|
||||||
"date": "2025-09-26T19:22:16Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "traefik/traefik",
|
"name": "traefik/traefik",
|
||||||
"version": "v3.5.3",
|
"version": "v3.5.3",
|
||||||
@@ -624,11 +639,6 @@
|
|||||||
"version": "v1.9.10",
|
"version": "v1.9.10",
|
||||||
"date": "2025-09-24T13:49:53Z"
|
"date": "2025-09-24T13:49:53Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "zwave-js/zwave-js-ui",
|
|
||||||
"version": "v11.3.1",
|
|
||||||
"date": "2025-09-24T11:58:00Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "syncthing/syncthing",
|
"name": "syncthing/syncthing",
|
||||||
"version": "v2.0.10",
|
"version": "v2.0.10",
|
||||||
@@ -719,11 +729,6 @@
|
|||||||
"version": "v0.23.2",
|
"version": "v0.23.2",
|
||||||
"date": "2025-09-18T17:18:59Z"
|
"date": "2025-09-18T17:18:59Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "grokability/snipe-it",
|
|
||||||
"version": "v8.3.2",
|
|
||||||
"date": "2025-09-18T13:55:58Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "NLnetLabs/unbound",
|
"name": "NLnetLabs/unbound",
|
||||||
"version": "release-1.24.0",
|
"version": "release-1.24.0",
|
||||||
@@ -1039,11 +1044,6 @@
|
|||||||
"version": "latest",
|
"version": "latest",
|
||||||
"date": "2025-08-15T15:33:51Z"
|
"date": "2025-08-15T15:33:51Z"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Kometa-Team/Kometa",
|
|
||||||
"version": "v2.2.1",
|
|
||||||
"date": "2025-08-13T19:49:01Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "swapplications/uhf-server-dist",
|
"name": "swapplications/uhf-server-dist",
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"documentation": "https://www.zigbee2mqtt.io/guide/getting-started/",
|
"documentation": "https://www.zigbee2mqtt.io/guide/getting-started/",
|
||||||
"website": "https://www.zigbee2mqtt.io/",
|
"website": "https://www.zigbee2mqtt.io/",
|
||||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/zigbee2mqtt.webp",
|
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/zigbee2mqtt.webp",
|
||||||
"config_path": "/opt/zigbee2mqtt/data/configuration.yaml",
|
"config_path": "debian: /opt/zigbee2mqtt/data/configuration.yaml | alpine: /var/lib/zigbee2mqtt/configuration.yaml",
|
||||||
"description": "Zigbee2MQTT is an open-source software project that allows you to use Zigbee-based smart home devices (such as those sold under the Philips Hue and Ikea Tradfri brands) with MQTT-based home automation systems, like Home Assistant, Node-RED, and others. The software acts as a bridge between your Zigbee devices and MQTT, allowing you to control and monitor these devices from your home automation system.",
|
"description": "Zigbee2MQTT is an open-source software project that allows you to use Zigbee-based smart home devices (such as those sold under the Philips Hue and Ikea Tradfri brands) with MQTT-based home automation systems, like Home Assistant, Node-RED, and others. The software acts as a bridge between your Zigbee devices and MQTT, allowing you to control and monitor these devices from your home automation system.",
|
||||||
"install_methods": [
|
"install_methods": [
|
||||||
{
|
{
|
||||||
|
|||||||
251
server.js
251
server.js
@@ -7,7 +7,7 @@ import { join, resolve } from 'path';
|
|||||||
import stripAnsi from 'strip-ansi';
|
import stripAnsi from 'strip-ansi';
|
||||||
import { spawn as ptySpawn } from 'node-pty';
|
import { spawn as ptySpawn } from 'node-pty';
|
||||||
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
||||||
import { getDatabase } from './src/server/database.js';
|
import { getDatabase } from './src/server/database-prisma.js';
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV !== 'production';
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
const hostname = '0.0.0.0';
|
const hostname = '0.0.0.0';
|
||||||
@@ -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,17 +131,66 @@ 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
|
||||||
* @param {string} scriptPath - Path to the script
|
* @param {string} scriptPath - Path to the script
|
||||||
* @param {string} executionMode - 'local' or 'ssh'
|
* @param {string} executionMode - 'local' or 'ssh'
|
||||||
* @param {number|null} serverId - Server ID for SSH executions
|
* @param {number|null} serverId - Server ID for SSH executions
|
||||||
* @returns {number|null} - Installation record ID
|
* @returns {Promise<number|null>} - Installation record ID
|
||||||
*/
|
*/
|
||||||
createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
|
async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
|
||||||
try {
|
try {
|
||||||
const result = this.db.createInstalledScript({
|
const result = await this.db.createInstalledScript({
|
||||||
script_name: scriptName,
|
script_name: scriptName,
|
||||||
script_path: scriptPath,
|
script_path: scriptPath,
|
||||||
container_id: undefined,
|
container_id: undefined,
|
||||||
@@ -149,7 +199,7 @@ class ScriptExecutionHandler {
|
|||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
output_log: ''
|
output_log: ''
|
||||||
});
|
});
|
||||||
return Number(result.lastInsertRowid);
|
return Number(result.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating installation record:', error);
|
console.error('Error creating installation record:', error);
|
||||||
return null;
|
return null;
|
||||||
@@ -161,9 +211,9 @@ class ScriptExecutionHandler {
|
|||||||
* @param {number} installationId - Installation record ID
|
* @param {number} installationId - Installation record ID
|
||||||
* @param {Object} updateData - Data to update
|
* @param {Object} updateData - Data to update
|
||||||
*/
|
*/
|
||||||
updateInstallationRecord(installationId, updateData) {
|
async updateInstallationRecord(installationId, updateData) {
|
||||||
try {
|
try {
|
||||||
this.db.updateInstalledScript(installationId, updateData);
|
await this.db.updateInstalledScript(installationId, updateData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating installation record:', error);
|
console.error('Error updating installation record:', error);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -275,7 +327,7 @@ class ScriptExecutionHandler {
|
|||||||
|
|
||||||
// Create installation record
|
// Create installation record
|
||||||
const serverId = server ? (server.id ?? null) : null;
|
const serverId = server ? (server.id ?? null) : null;
|
||||||
installationId = this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
|
installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
|
||||||
|
|
||||||
if (!installationId) {
|
if (!installationId) {
|
||||||
console.error('Failed to create installation record');
|
console.error('Failed to create installation record');
|
||||||
@@ -304,7 +356,7 @@ class ScriptExecutionHandler {
|
|||||||
|
|
||||||
// Update installation record with failure
|
// Update installation record with failure
|
||||||
if (installationId) {
|
if (installationId) {
|
||||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -342,7 +394,7 @@ class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle pty data (both stdout and stderr combined)
|
// Handle pty data (both stdout and stderr combined)
|
||||||
childProcess.onData((data) => {
|
childProcess.onData(async (data) => {
|
||||||
const output = data.toString();
|
const output = data.toString();
|
||||||
|
|
||||||
// Store output in buffer for logging
|
// Store output in buffer for logging
|
||||||
@@ -358,7 +410,19 @@ class ScriptExecutionHandler {
|
|||||||
// Parse for Container ID
|
// Parse for Container ID
|
||||||
const containerId = this.parseContainerId(output);
|
const containerId = this.parseContainerId(output);
|
||||||
if (containerId && installationId) {
|
if (containerId && installationId) {
|
||||||
this.updateInstallationRecord(installationId, { container_id: containerId });
|
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse for Web UI URL
|
||||||
|
const webUIUrl = this.parseWebUIUrl(output);
|
||||||
|
if (webUIUrl && installationId) {
|
||||||
|
const { ip, port } = webUIUrl;
|
||||||
|
if (ip && port) {
|
||||||
|
await this.updateInstallationRecord(installationId, {
|
||||||
|
web_ui_ip: ip,
|
||||||
|
web_ui_port: port
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
@@ -400,7 +464,7 @@ class ScriptExecutionHandler {
|
|||||||
|
|
||||||
// Update installation record with failure
|
// Update installation record with failure
|
||||||
if (installationId) {
|
if (installationId) {
|
||||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,7 +491,7 @@ class ScriptExecutionHandler {
|
|||||||
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
|
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
|
||||||
server,
|
server,
|
||||||
scriptPath,
|
scriptPath,
|
||||||
/** @param {string} data */ (data) => {
|
/** @param {string} data */ async (data) => {
|
||||||
// Store output in buffer for logging
|
// Store output in buffer for logging
|
||||||
const exec = this.activeExecutions.get(executionId);
|
const exec = this.activeExecutions.get(executionId);
|
||||||
if (exec) {
|
if (exec) {
|
||||||
@@ -441,7 +505,19 @@ class ScriptExecutionHandler {
|
|||||||
// Parse for Container ID
|
// Parse for Container ID
|
||||||
const containerId = this.parseContainerId(data);
|
const containerId = this.parseContainerId(data);
|
||||||
if (containerId && installationId) {
|
if (containerId && installationId) {
|
||||||
this.updateInstallationRecord(installationId, { container_id: containerId });
|
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse for Web UI URL
|
||||||
|
const webUIUrl = this.parseWebUIUrl(data);
|
||||||
|
if (webUIUrl && installationId) {
|
||||||
|
const { ip, port } = webUIUrl;
|
||||||
|
if (ip && port) {
|
||||||
|
await this.updateInstallationRecord(installationId, {
|
||||||
|
web_ui_ip: ip,
|
||||||
|
web_ui_port: port
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle data output
|
// Handle data output
|
||||||
@@ -469,13 +545,13 @@ class ScriptExecutionHandler {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
/** @param {number} code */ (code) => {
|
/** @param {number} code */ async (code) => {
|
||||||
const exec = this.activeExecutions.get(executionId);
|
const exec = this.activeExecutions.get(executionId);
|
||||||
const isSuccess = code === 0;
|
const isSuccess = code === 0;
|
||||||
|
|
||||||
// Update installation record with final status and output
|
// Update installation record with final status and output
|
||||||
if (installationId && exec) {
|
if (installationId && exec) {
|
||||||
this.updateInstallationRecord(installationId, {
|
await this.updateInstallationRecord(installationId, {
|
||||||
status: isSuccess ? 'success' : 'failed',
|
status: isSuccess ? 'success' : 'failed',
|
||||||
output_log: exec.outputBuffer
|
output_log: exec.outputBuffer
|
||||||
});
|
});
|
||||||
@@ -510,7 +586,7 @@ class ScriptExecutionHandler {
|
|||||||
|
|
||||||
// Update installation record with failure
|
// Update installation record with failure
|
||||||
if (installationId) {
|
if (installationId) {
|
||||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -709,6 +785,145 @@ class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start shell execution
|
||||||
|
* @param {ExtendedWebSocket} ws
|
||||||
|
* @param {string} containerId
|
||||||
|
* @param {string} executionId
|
||||||
|
* @param {string} mode
|
||||||
|
* @param {ServerInfo|null} server
|
||||||
|
*/
|
||||||
|
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Send start message
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'start',
|
||||||
|
data: `Starting shell session for container ${containerId}...`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === 'ssh' && server) {
|
||||||
|
await this.startSSHShellExecution(ws, containerId, executionId, server);
|
||||||
|
} else {
|
||||||
|
await this.startLocalShellExecution(ws, containerId, executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: `Failed to start shell: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start local shell execution
|
||||||
|
* @param {ExtendedWebSocket} ws
|
||||||
|
* @param {string} containerId
|
||||||
|
* @param {string} executionId
|
||||||
|
*/
|
||||||
|
async startLocalShellExecution(ws, containerId, executionId) {
|
||||||
|
const { spawn } = await import('node-pty');
|
||||||
|
|
||||||
|
// Create a shell process that will run pct enter
|
||||||
|
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
|
||||||
|
name: 'xterm-color',
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: process.env
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the execution
|
||||||
|
this.activeExecutions.set(executionId, {
|
||||||
|
process: childProcess,
|
||||||
|
ws
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle pty data
|
||||||
|
childProcess.onData((data) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: data.toString(),
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: No automatic command is sent - user can type commands interactively
|
||||||
|
|
||||||
|
// Handle process exit
|
||||||
|
childProcess.onExit((e) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'end',
|
||||||
|
data: `Shell session ended with exit code: ${e.exitCode}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeExecutions.delete(executionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start SSH shell execution
|
||||||
|
* @param {ExtendedWebSocket} ws
|
||||||
|
* @param {string} containerId
|
||||||
|
* @param {string} executionId
|
||||||
|
* @param {ServerInfo} server
|
||||||
|
*/
|
||||||
|
async startSSHShellExecution(ws, containerId, executionId, server) {
|
||||||
|
const sshService = getSSHExecutionService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const execution = await sshService.executeCommand(
|
||||||
|
server,
|
||||||
|
`pct enter ${containerId}`,
|
||||||
|
/** @param {string} data */
|
||||||
|
(data) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {string} error */
|
||||||
|
(error) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: error,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** @param {number} code */
|
||||||
|
(code) => {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'end',
|
||||||
|
data: `Shell session ended with exit code: ${code}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeExecutions.delete(executionId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the execution
|
||||||
|
this.activeExecutions.set(executionId, {
|
||||||
|
process: /** @type {any} */ (execution).process,
|
||||||
|
ws
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: No automatic command is sent - user can type commands interactively
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: `SSH shell execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TerminalHandler removed - not used by current application
|
// 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;
|
||||||
|
}
|
||||||
@@ -16,15 +16,15 @@ export function Badge({ variant, type, noteType, status, executionMode, children
|
|||||||
const getTypeStyles = (scriptType: string) => {
|
const getTypeStyles = (scriptType: string) => {
|
||||||
switch (scriptType.toLowerCase()) {
|
switch (scriptType.toLowerCase()) {
|
||||||
case 'ct':
|
case 'ct':
|
||||||
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-700';
|
return 'bg-primary/10 text-primary border-primary/20';
|
||||||
case 'addon':
|
case 'addon':
|
||||||
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border-purple-200 dark:border-purple-700';
|
return 'bg-purple-500/10 text-purple-400 border-purple-500/20';
|
||||||
case 'vm':
|
case 'vm':
|
||||||
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-700';
|
return 'bg-green-500/10 text-green-400 border-green-500/20';
|
||||||
case 'pve':
|
case 'pve':
|
||||||
return 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 border-orange-200 dark:border-orange-700';
|
return 'bg-orange-500/10 text-orange-400 border-orange-500/20';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border-gray-200 dark:border-gray-600';
|
return 'bg-muted text-muted-foreground border-border';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,45 +34,45 @@ export function Badge({ variant, type, noteType, status, executionMode, children
|
|||||||
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles('unknown')}`;
|
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles('unknown')}`;
|
||||||
|
|
||||||
case 'updateable':
|
case 'updateable':
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-700';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20';
|
||||||
|
|
||||||
case 'privileged':
|
case 'privileged':
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-700';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
|
||||||
|
|
||||||
case 'status':
|
case 'status':
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'success':
|
case 'success':
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-700';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20';
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-700';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-700';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20';
|
||||||
default:
|
default:
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'execution-mode':
|
case 'execution-mode':
|
||||||
switch (executionMode) {
|
switch (executionMode) {
|
||||||
case 'local':
|
case 'local':
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-700';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
|
||||||
case 'ssh':
|
case 'ssh':
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-700';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20';
|
||||||
default:
|
default:
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'note':
|
case 'note':
|
||||||
switch (noteType) {
|
switch (noteType) {
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-700';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-700';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
|
||||||
default:
|
default:
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-700';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600';
|
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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: (
|
||||||
@@ -195,24 +196,27 @@ export function CategorySidebar({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 transition-all duration-300 ${
|
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
|
||||||
isCollapsed ? 'w-16' : 'w-80'
|
isCollapsed ? 'w-16' : 'w-full lg:w-80'
|
||||||
}`}>
|
}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Categories</h3>
|
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">{totalScripts} Total scripts</p>
|
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
|
||||||
|
</div>
|
||||||
|
<ContextualHelpIcon section="available-scripts" tooltip="Help with categories" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
title={isCollapsed ? 'Expand categories' : 'Collapse categories'}
|
title={isCollapsed ? 'Expand categories' : 'Collapse categories'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform ${
|
className={`w-5 h-5 text-muted-foreground transition-transform ${
|
||||||
isCollapsed ? 'rotate-180' : ''
|
isCollapsed ? 'rotate-180' : ''
|
||||||
}`}
|
}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -233,21 +237,21 @@ export function CategorySidebar({
|
|||||||
onClick={() => onCategorySelect(null)}
|
onClick={() => onCategorySelect(null)}
|
||||||
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
|
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
|
||||||
selectedCategory === null
|
selectedCategory === null
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
|
? 'bg-primary/10 text-primary border border-primary/20'
|
||||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
: 'hover:bg-accent text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
iconName="template"
|
iconName="template"
|
||||||
className={`w-5 h-5 ${selectedCategory === null ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400'}`}
|
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">All Categories</span>
|
<span className="font-medium">All Categories</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm px-2 py-1 rounded-full ${
|
<span className={`text-sm px-2 py-1 rounded-full ${
|
||||||
selectedCategory === null
|
selectedCategory === null
|
||||||
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300'
|
? 'bg-primary/20 text-primary'
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{totalScripts}
|
{totalScripts}
|
||||||
</span>
|
</span>
|
||||||
@@ -263,14 +267,14 @@ export function CategorySidebar({
|
|||||||
onClick={() => onCategorySelect(category)}
|
onClick={() => onCategorySelect(category)}
|
||||||
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
|
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
|
? 'bg-primary/10 text-primary border border-primary/20'
|
||||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
: 'hover:bg-accent text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
iconName={categoryIconMapping[category] ?? 'box'}
|
iconName={categoryIconMapping[category] ?? 'box'}
|
||||||
className={`w-5 h-5 ${isSelected ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400'}`}
|
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium capitalize">
|
<span className="font-medium capitalize">
|
||||||
{category.replace(/[_-]/g, ' ')}
|
{category.replace(/[_-]/g, ' ')}
|
||||||
@@ -278,8 +282,8 @@ export function CategorySidebar({
|
|||||||
</div>
|
</div>
|
||||||
<span className={`text-sm px-2 py-1 rounded-full ${
|
<span className={`text-sm px-2 py-1 rounded-full ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300'
|
? 'bg-primary/20 text-primary'
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
@@ -292,32 +296,32 @@ export function CategorySidebar({
|
|||||||
|
|
||||||
{/* Collapsed state - show only icons with counters and tooltips */}
|
{/* Collapsed state - show only icons with counters and tooltips */}
|
||||||
{isCollapsed && (
|
{isCollapsed && (
|
||||||
<div className="p-2 flex flex-col space-y-2">
|
<div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
|
||||||
{/* "All Categories" option */}
|
{/* "All Categories" option */}
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => onCategorySelect(null)}
|
onClick={() => onCategorySelect(null)}
|
||||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
|
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
|
||||||
selectedCategory === null
|
selectedCategory === null
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
|
? 'bg-primary/10 text-primary border border-primary/20'
|
||||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
: 'hover:bg-accent text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
iconName="template"
|
iconName="template"
|
||||||
className={`w-5 h-5 ${selectedCategory === null ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200'}`}
|
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs mt-1 px-1 rounded ${
|
<span className={`text-xs mt-1 px-1 rounded ${
|
||||||
selectedCategory === null
|
selectedCategory === null
|
||||||
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300'
|
? 'bg-primary/20 text-primary'
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{totalScripts}
|
{totalScripts}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* Tooltip */}
|
||||||
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 dark:bg-gray-700 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
|
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
|
||||||
All Categories ({totalScripts})
|
All Categories ({totalScripts})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,25 +336,25 @@ export function CategorySidebar({
|
|||||||
onClick={() => onCategorySelect(category)}
|
onClick={() => onCategorySelect(category)}
|
||||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
|
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
|
? 'bg-primary/10 text-primary border border-primary/20'
|
||||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
: 'hover:bg-accent text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
iconName={categoryIconMapping[category] ?? 'box'}
|
iconName={categoryIconMapping[category] ?? 'box'}
|
||||||
className={`w-5 h-5 ${isSelected ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200'}`}
|
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs mt-1 px-1 rounded ${
|
<span className={`text-xs mt-1 px-1 rounded ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300'
|
? 'bg-primary/20 text-primary'
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* Tooltip */}
|
||||||
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 dark:bg-gray-700 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
|
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
|
||||||
{category} ({count})
|
{category} ({count})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
|
||||||
|
|
||||||
interface DarkModeContextType {
|
|
||||||
theme: Theme;
|
|
||||||
setTheme: (theme: Theme) => void;
|
|
||||||
isDark: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function DarkModeProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [theme, setThemeState] = useState<Theme>('system');
|
|
||||||
const [isDark, setIsDark] = useState(false);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
// Initialize theme from localStorage after mount
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = localStorage.getItem('theme') as Theme;
|
|
||||||
if (stored && ['light', 'dark', 'system'].includes(stored)) {
|
|
||||||
setThemeState(stored);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial isDark state based on current DOM state
|
|
||||||
const currentlyDark = document.documentElement.classList.contains('dark');
|
|
||||||
setIsDark(currentlyDark);
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update dark mode state and DOM when theme changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
const updateDarkMode = () => {
|
|
||||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
const shouldBeDark = theme === 'dark' || (theme === 'system' && systemDark);
|
|
||||||
|
|
||||||
// Only update if there's actually a change
|
|
||||||
if (shouldBeDark !== isDark) {
|
|
||||||
setIsDark(shouldBeDark);
|
|
||||||
|
|
||||||
// Apply to document
|
|
||||||
if (shouldBeDark) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateDarkMode();
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
const handleChange = () => {
|
|
||||||
if (theme === 'system') {
|
|
||||||
updateDarkMode();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
|
||||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
||||||
}, [theme, mounted, isDark]);
|
|
||||||
|
|
||||||
const setTheme = (newTheme: Theme) => {
|
|
||||||
setThemeState(newTheme);
|
|
||||||
localStorage.setItem('theme', newTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DarkModeContext.Provider value={{ theme, setTheme, isDark }}>
|
|
||||||
{children}
|
|
||||||
</DarkModeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDarkMode() {
|
|
||||||
const context = useContext(DarkModeContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useDarkMode must be used within a DarkModeProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useDarkMode } from './DarkModeProvider';
|
|
||||||
|
|
||||||
export function DarkModeToggle() {
|
|
||||||
const { theme, setTheme, isDark } = useDarkMode();
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
if (theme === 'light') {
|
|
||||||
setTheme('dark');
|
|
||||||
} else if (theme === 'dark') {
|
|
||||||
setTheme('system');
|
|
||||||
} else {
|
|
||||||
setTheme('light');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIcon = () => {
|
|
||||||
if (theme === 'light') {
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
} else if (theme === 'dark') {
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// System theme icon
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7l.159-1.591A3.001 3.001 0 0112 8a3 3 0 01-3.229 2.409L8.771 12z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLabel = () => {
|
|
||||||
if (theme === 'light') return 'Light mode';
|
|
||||||
if (theme === 'dark') return 'Dark mode';
|
|
||||||
return 'System theme';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className={`
|
|
||||||
flex items-center justify-center
|
|
||||||
w-10 h-10 rounded-lg
|
|
||||||
transition-all duration-200
|
|
||||||
hover:scale-105 active:scale-95
|
|
||||||
${isDark
|
|
||||||
? 'bg-gray-800 text-yellow-400 hover:bg-gray-700 border border-gray-600'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-300'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
title={getLabel()}
|
|
||||||
aria-label={getLabel()}
|
|
||||||
>
|
|
||||||
{getIcon()}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -45,17 +45,17 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
|||||||
key={index}
|
key={index}
|
||||||
className={`flex font-mono text-sm ${
|
className={`flex font-mono text-sm ${
|
||||||
isAdded
|
isAdded
|
||||||
? 'bg-green-50 text-green-800 border-l-4 border-green-400'
|
? 'bg-green-500/10 text-green-400 border-l-4 border-green-500'
|
||||||
: isRemoved
|
: isRemoved
|
||||||
? 'bg-red-50 text-red-800 border-l-4 border-red-400'
|
? 'bg-destructive/10 text-destructive border-l-4 border-destructive'
|
||||||
: 'bg-gray-50 text-gray-700'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-16 text-right pr-2 text-gray-500 select-none">
|
<div className="w-16 text-right pr-2 text-muted-foreground select-none">
|
||||||
{lineNumber}
|
{lineNumber}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 pl-2">
|
<div className="flex-1 pl-2">
|
||||||
<span className={isAdded ? 'text-green-600' : isRemoved ? 'text-red-600' : ''}>
|
<span className={isAdded ? 'text-green-400' : isRemoved ? 'text-destructive' : ''}>
|
||||||
{isAdded ? '+' : isRemoved ? '-' : ' '}
|
{isAdded ? '+' : isRemoved ? '-' : ' '}
|
||||||
</span>
|
</span>
|
||||||
<span className="whitespace-pre-wrap">{content}</span>
|
<span className="whitespace-pre-wrap">{content}</span>
|
||||||
@@ -66,27 +66,27 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
>
|
>
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
|
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden border border-border mx-4 sm:mx-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-gray-900">Script Diff</h2>
|
<h2 className="text-xl font-bold text-foreground">Script Diff</h2>
|
||||||
<p className="text-sm text-gray-600">{filePath}</p>
|
<p className="text-sm text-muted-foreground">{filePath}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors disabled:opacity-50"
|
className="px-3 py-1 text-sm bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -96,19 +96,19 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="px-4 py-2 bg-gray-50 border-b border-gray-200">
|
<div className="px-4 py-2 bg-muted border-b border-border">
|
||||||
<div className="flex items-center space-x-4 text-sm">
|
<div className="flex items-center space-x-4 text-sm">
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<div className="w-3 h-3 bg-green-100 border border-green-300"></div>
|
<div className="w-3 h-3 bg-green-500/20 border border-green-500/40"></div>
|
||||||
<span className="text-green-700">Added (Remote)</span>
|
<span className="text-green-400">Added (Remote)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<div className="w-3 h-3 bg-red-100 border border-red-300"></div>
|
<div className="w-3 h-3 bg-destructive/20 border border-destructive/40"></div>
|
||||||
<span className="text-red-700">Removed (Local)</span>
|
<span className="text-destructive">Removed (Local)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<div className="w-3 h-3 bg-gray-100 border border-gray-300"></div>
|
<div className="w-3 h-3 bg-muted border border-border"></div>
|
||||||
<span className="text-gray-700">Unchanged</span>
|
<span className="text-muted-foreground">Unchanged</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,14 +117,14 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
|||||||
<div className="overflow-y-auto max-h-[calc(90vh-120px)]">
|
<div className="overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||||
{diffData?.success ? (
|
{diffData?.success ? (
|
||||||
diffData.diff ? (
|
diffData.diff ? (
|
||||||
<div className="divide-y divide-gray-200">
|
<div className="divide-y divide-border">
|
||||||
{diffData.diff.split('\n').map((line, index) =>
|
{diffData.diff.split('\n').map((line, index) =>
|
||||||
line.trim() ? renderDiffLine(line, index) : null
|
line.trim() ? renderDiffLine(line, index) : null
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center text-gray-500">
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
<svg className="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-12 h-12 mx-auto mb-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p>No differences found</p>
|
<p>No differences found</p>
|
||||||
@@ -132,16 +132,16 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : diffData?.error ? (
|
) : diffData?.error ? (
|
||||||
<div className="p-8 text-center text-red-500">
|
<div className="p-8 text-center text-destructive">
|
||||||
<svg className="w-12 h-12 mx-auto mb-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-12 h-12 mx-auto mb-4 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p>Error loading diff</p>
|
<p>Error loading diff</p>
|
||||||
<p className="text-sm">{diffData.error}</p>
|
<p className="text-sm">{diffData.error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center text-gray-500">
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||||
<p>Loading diff...</p>
|
<p>Loading diff...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
527
src/app/_components/DownloadedScriptsTab.tsx
Normal file
527
src/app/_components/DownloadedScriptsTab.tsx
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
import { ScriptCard } from './ScriptCard';
|
||||||
|
import { ScriptCardList } from './ScriptCardList';
|
||||||
|
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||||
|
import { CategorySidebar } from './CategorySidebar';
|
||||||
|
import { FilterBar, type FilterState } from './FilterBar';
|
||||||
|
import { ViewToggle } from './ViewToggle';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||||
|
|
||||||
|
interface DownloadedScriptsTabProps {
|
||||||
|
onInstallScript?: (
|
||||||
|
scriptPath: string,
|
||||||
|
scriptName: string,
|
||||||
|
mode?: "local" | "ssh",
|
||||||
|
server?: any,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
|
||||||
|
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
searchQuery: '',
|
||||||
|
showUpdatable: null,
|
||||||
|
selectedTypes: [],
|
||||||
|
sortBy: 'name',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
});
|
||||||
|
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||||
|
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
|
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||||
|
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||||
|
{ slug: selectedSlug ?? '' },
|
||||||
|
{ enabled: !!selectedSlug }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
// Load SAVE_FILTER setting
|
||||||
|
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||||
|
let saveFilterEnabled = false;
|
||||||
|
if (saveFilterResponse.ok) {
|
||||||
|
const saveFilterData = await saveFilterResponse.json();
|
||||||
|
saveFilterEnabled = saveFilterData.enabled ?? false;
|
||||||
|
setSaveFiltersEnabled(saveFilterEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved filters if SAVE_FILTER is enabled
|
||||||
|
if (saveFilterEnabled) {
|
||||||
|
const filtersResponse = await fetch('/api/settings/filters');
|
||||||
|
if (filtersResponse.ok) {
|
||||||
|
const filtersData = await filtersResponse.json();
|
||||||
|
if (filtersData.filters) {
|
||||||
|
setFilters(filtersData.filters as FilterState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load view mode
|
||||||
|
const viewModeResponse = await fetch('/api/settings/view-mode');
|
||||||
|
if (viewModeResponse.ok) {
|
||||||
|
const viewModeData = await viewModeResponse.json();
|
||||||
|
const viewMode = viewModeData.viewMode;
|
||||||
|
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
|
||||||
|
setViewMode(viewMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingFilters(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save filters when they change (if SAVE_FILTER is enabled)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!saveFiltersEnabled || isLoadingFilters) return;
|
||||||
|
|
||||||
|
const saveFilters = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings/filters', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filters }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving filters:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
const timeoutId = setTimeout(() => void saveFilters(), 500);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||||
|
|
||||||
|
// Save view mode when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoadingFilters) return;
|
||||||
|
|
||||||
|
const saveViewMode = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings/view-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ viewMode }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving view mode:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
const timeoutId = setTimeout(() => void saveViewMode(), 300);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [viewMode, isLoadingFilters]);
|
||||||
|
|
||||||
|
// Extract categories from metadata
|
||||||
|
const categories = React.useMemo((): string[] => {
|
||||||
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
|
|
||||||
|
return (scriptCardsData.metadata.categories as any[])
|
||||||
|
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
.map((cat) => cat.name as string)
|
||||||
|
.filter((name): name is string => typeof name === 'string');
|
||||||
|
}, [scriptCardsData]);
|
||||||
|
|
||||||
|
// Get GitHub scripts with download status (deduplicated)
|
||||||
|
const combinedScripts = React.useMemo((): ScriptCardType[] => {
|
||||||
|
if (!scriptCardsData?.success) return [];
|
||||||
|
|
||||||
|
// Use Map to deduplicate by slug/name
|
||||||
|
const scriptMap = new Map<string, ScriptCardType>();
|
||||||
|
|
||||||
|
scriptCardsData.cards?.forEach(script => {
|
||||||
|
if (script?.name && script?.slug) {
|
||||||
|
// Use slug as unique identifier, only keep first occurrence
|
||||||
|
if (!scriptMap.has(script.slug)) {
|
||||||
|
scriptMap.set(script.slug, {
|
||||||
|
...script,
|
||||||
|
source: 'github' as const,
|
||||||
|
isDownloaded: false, // Will be updated by status check
|
||||||
|
isUpToDate: false, // Will be updated by status check
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(scriptMap.values());
|
||||||
|
}, [scriptCardsData]);
|
||||||
|
|
||||||
|
// Update scripts with download status and filter to only downloaded scripts
|
||||||
|
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
|
||||||
|
return combinedScripts
|
||||||
|
.map(script => {
|
||||||
|
if (!script?.name) {
|
||||||
|
return script; // Return as-is if invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's a corresponding local script
|
||||||
|
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
|
||||||
|
if (!local?.name) return false;
|
||||||
|
const localName = local.name.replace(/\.sh$/, '');
|
||||||
|
return localName.toLowerCase() === script.name.toLowerCase() ||
|
||||||
|
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
|
||||||
|
}) ?? false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...script,
|
||||||
|
isDownloaded: hasLocalVersion,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(script => script.isDownloaded); // Only show downloaded scripts
|
||||||
|
}, [combinedScripts, localScriptsData]);
|
||||||
|
|
||||||
|
// Count scripts per category (using downloaded scripts only)
|
||||||
|
const categoryCounts = React.useMemo((): Record<string, number> => {
|
||||||
|
if (!scriptCardsData?.success) return {};
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
|
||||||
|
// Initialize all categories with 0
|
||||||
|
categories.forEach((categoryName: string) => {
|
||||||
|
counts[categoryName] = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count each unique downloaded script only once per category
|
||||||
|
downloadedScripts.forEach(script => {
|
||||||
|
if (script.categoryNames && script.slug) {
|
||||||
|
const countedCategories = new Set<string>();
|
||||||
|
script.categoryNames.forEach((categoryName: unknown) => {
|
||||||
|
if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) {
|
||||||
|
countedCategories.add(categoryName);
|
||||||
|
counts[categoryName]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}, [categories, downloadedScripts, scriptCardsData?.success]);
|
||||||
|
|
||||||
|
// Filter scripts based on all filters and category
|
||||||
|
const filteredScripts = React.useMemo((): ScriptCardType[] => {
|
||||||
|
let scripts = downloadedScripts;
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (filters.searchQuery?.trim()) {
|
||||||
|
const query = filters.searchQuery.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (query.length >= 1) {
|
||||||
|
scripts = scripts.filter(script => {
|
||||||
|
if (!script || typeof script !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = (script.name ?? '').toLowerCase();
|
||||||
|
const slug = (script.slug ?? '').toLowerCase();
|
||||||
|
|
||||||
|
return name.includes(query) ?? slug.includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by category using real category data from downloaded scripts
|
||||||
|
if (selectedCategory) {
|
||||||
|
scripts = scripts.filter(script => {
|
||||||
|
if (!script) return false;
|
||||||
|
|
||||||
|
// Check if the downloaded script has categoryNames that include the selected category
|
||||||
|
return script.categoryNames?.includes(selectedCategory) ?? false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by updateable status
|
||||||
|
if (filters.showUpdatable !== null) {
|
||||||
|
scripts = scripts.filter(script => {
|
||||||
|
if (!script) return false;
|
||||||
|
const isUpdatable = script.updateable ?? false;
|
||||||
|
return filters.showUpdatable ? isUpdatable : !isUpdatable;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by script types
|
||||||
|
if (filters.selectedTypes.length > 0) {
|
||||||
|
scripts = scripts.filter(script => {
|
||||||
|
if (!script) return false;
|
||||||
|
const scriptType = (script.type ?? '').toLowerCase();
|
||||||
|
return filters.selectedTypes.some(type => type.toLowerCase() === scriptType);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
scripts.sort((a, b) => {
|
||||||
|
if (!a || !b) return 0;
|
||||||
|
|
||||||
|
let compareValue = 0;
|
||||||
|
|
||||||
|
switch (filters.sortBy) {
|
||||||
|
case 'name':
|
||||||
|
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||||
|
break;
|
||||||
|
case 'created':
|
||||||
|
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
|
||||||
|
const aCreated = a?.date_created ?? '';
|
||||||
|
const bCreated = b?.date_created ?? '';
|
||||||
|
|
||||||
|
// If both have dates, compare them directly
|
||||||
|
if (aCreated && bCreated) {
|
||||||
|
// For dates: asc = oldest first (2020 before 2024), desc = newest first (2024 before 2020)
|
||||||
|
compareValue = aCreated.localeCompare(bCreated);
|
||||||
|
} else if (aCreated && !bCreated) {
|
||||||
|
// Scripts with dates come before scripts without dates
|
||||||
|
compareValue = -1;
|
||||||
|
} else if (!aCreated && bCreated) {
|
||||||
|
// Scripts without dates come after scripts with dates
|
||||||
|
compareValue = 1;
|
||||||
|
} else {
|
||||||
|
// Both have no dates, fallback to name comparison
|
||||||
|
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sort order
|
||||||
|
return filters.sortOrder === 'asc' ? compareValue : -compareValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
return scripts;
|
||||||
|
}, [downloadedScripts, filters, selectedCategory]);
|
||||||
|
|
||||||
|
// Calculate filter counts for FilterBar
|
||||||
|
const filterCounts = React.useMemo(() => {
|
||||||
|
const updatableCount = downloadedScripts.filter(script => script?.updateable).length;
|
||||||
|
|
||||||
|
return { installedCount: downloadedScripts.length, updatableCount };
|
||||||
|
}, [downloadedScripts]);
|
||||||
|
|
||||||
|
// Handle filter changes
|
||||||
|
const handleFiltersChange = (newFilters: FilterState) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle category selection with auto-scroll
|
||||||
|
const handleCategorySelect = (category: string | null) => {
|
||||||
|
setSelectedCategory(category);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-scroll effect when category changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCategory && gridRef.current) {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
gridRef.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
inline: 'nearest'
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
|
const handleCardClick = (scriptCard: { slug: string }) => {
|
||||||
|
// All scripts are GitHub scripts, open modal
|
||||||
|
setSelectedSlug(scriptCard.slug);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedSlug(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (githubLoading || localLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (githubError || localError) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-red-600 mb-4">
|
||||||
|
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-lg font-medium">Failed to load downloaded scripts</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!downloadedScripts?.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-lg font-medium">No downloaded scripts found</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
You haven't downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||||
|
{/* Category Sidebar */}
|
||||||
|
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||||
|
<CategorySidebar
|
||||||
|
categories={categories}
|
||||||
|
categoryCounts={categoryCounts}
|
||||||
|
totalScripts={downloadedScripts.length}
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
onCategorySelect={handleCategorySelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
||||||
|
{/* Enhanced Filter Bar */}
|
||||||
|
<FilterBar
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={handleFiltersChange}
|
||||||
|
totalScripts={downloadedScripts.length}
|
||||||
|
filteredCount={filteredScripts.length}
|
||||||
|
updatableCount={filterCounts.updatableCount}
|
||||||
|
saveFiltersEnabled={saveFiltersEnabled}
|
||||||
|
isLoadingFilters={isLoadingFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<ViewToggle
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scripts Grid */}
|
||||||
|
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-lg font-medium">No matching downloaded scripts found</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Try different filter settings or clear all filters.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center gap-2 mt-4">
|
||||||
|
{filters.searchQuery && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
Clear Search
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedCategory && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleCategorySelect(null)}
|
||||||
|
variant="secondary"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
Clear Category
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
viewMode === 'card' ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{filteredScripts.map((script, index) => {
|
||||||
|
// Add validation to ensure script has required properties
|
||||||
|
if (!script || typeof script !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScriptCard
|
||||||
|
key={uniqueKey}
|
||||||
|
script={script}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredScripts.map((script, index) => {
|
||||||
|
// Add validation to ensure script has required properties
|
||||||
|
if (!script || typeof script !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||||
|
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScriptCardList
|
||||||
|
key={uniqueKey}
|
||||||
|
script={script}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScriptDetailModal
|
||||||
|
script={scriptData?.success ? scriptData.script : null}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
onInstallScript={onInstallScript}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { Server } from '../../types/server';
|
import type { Server } from '../../types/server';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
||||||
|
import { SettingsModal } from './SettingsModal';
|
||||||
|
|
||||||
|
|
||||||
interface ExecutionModeModalProps {
|
interface ExecutionModeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -14,8 +18,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local');
|
|
||||||
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
||||||
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -23,6 +27,20 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Auto-select server when exactly one server is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !loading && servers.length === 1) {
|
||||||
|
setSelectedServer(servers[0] ?? null);
|
||||||
|
}
|
||||||
|
}, [isOpen, loading, servers]);
|
||||||
|
|
||||||
|
// Refresh servers when settings modal closes
|
||||||
|
const handleSettingsModalClose = () => {
|
||||||
|
setSettingsModalOpen(false);
|
||||||
|
// Refetch servers to reflect any changes made in settings
|
||||||
|
void fetchServers();
|
||||||
|
};
|
||||||
|
|
||||||
const fetchServers = async () => {
|
const fetchServers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -32,7 +50,11 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
throw new Error('Failed to fetch servers');
|
throw new Error('Failed to fetch servers');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setServers(data as Server[]);
|
// Sort servers by name alphabetically
|
||||||
|
const sortedServers = (data as Server[]).sort((a, b) =>
|
||||||
|
(a.name ?? '').localeCompare(b.name ?? '')
|
||||||
|
);
|
||||||
|
setServers(sortedServers);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,166 +63,175 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExecute = () => {
|
const handleExecute = () => {
|
||||||
if (selectedMode === 'ssh' && !selectedServer) {
|
if (!selectedServer) {
|
||||||
setError('Please select a server for SSH execution');
|
setError('Please select a server for SSH execution');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onExecute(selectedMode, selectedServer ?? undefined);
|
onExecute('ssh', selectedServer);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModeChange = (mode: 'local' | 'ssh') => {
|
|
||||||
setSelectedMode(mode);
|
const handleServerSelect = (server: Server | null) => {
|
||||||
if (mode === 'local') {
|
setSelectedServer(server);
|
||||||
setSelectedServer(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<>
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<h2 className="text-xl font-bold text-gray-900">Execution Mode</h2>
|
<h2 className="text-xl font-bold text-foreground">Select Server</h2>
|
||||||
<button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
Where would you like to execute "{scriptName}"?
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<p className="text-sm text-red-700">{error}</p>
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Execution Mode Selection */}
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
|
|
||||||
|
|
||||||
{/* SSH Execution */}
|
|
||||||
<div
|
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
|
||||||
selectedMode === 'ssh'
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleModeChange('ssh')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id="ssh"
|
|
||||||
name="executionMode"
|
|
||||||
value="ssh"
|
|
||||||
checked={selectedMode === 'ssh'}
|
|
||||||
onChange={() => handleModeChange('ssh')}
|
|
||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
|
||||||
/>
|
|
||||||
<label htmlFor="ssh" className="ml-3 flex-1 cursor-pointer">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900">SSH Execution</h4>
|
|
||||||
<p className="text-sm text-gray-500">Run the script on a remote server</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Server Selection (only for SSH mode) */}
|
|
||||||
{selectedMode === 'ssh' && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<label htmlFor="server" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Select Server
|
|
||||||
</label>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-8">
|
||||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
<p className="mt-2 text-sm text-gray-600">Loading servers...</p>
|
<p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
|
||||||
</div>
|
</div>
|
||||||
) : servers.length === 0 ? (
|
) : servers.length === 0 ? (
|
||||||
<div className="text-center py-4 text-gray-500">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<p className="text-sm">No servers configured</p>
|
<p className="text-sm">No servers configured</p>
|
||||||
<p className="text-xs mt-1">Add servers in Settings to use SSH execution</p>
|
<p className="text-xs mt-1">Add servers in Settings to execute scripts</p>
|
||||||
</div>
|
<Button
|
||||||
) : (
|
onClick={() => setSettingsModalOpen(true)}
|
||||||
<select
|
variant="outline"
|
||||||
id="server"
|
size="sm"
|
||||||
value={selectedServer?.id ?? ''}
|
className="mt-3"
|
||||||
onChange={(e) => {
|
|
||||||
const serverId = parseInt(e.target.value);
|
|
||||||
const server = servers.find(s => s.id === serverId);
|
|
||||||
setSelectedServer(server ?? null);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
>
|
>
|
||||||
<option value="">Select a server...</option>
|
Open Server Settings
|
||||||
{servers.map((server) => (
|
</Button>
|
||||||
<option key={server.id} value={server.id}>
|
</div>
|
||||||
{server.name} ({server.ip}) - {server.user}
|
) : servers.length === 1 ? (
|
||||||
</option>
|
/* Single Server Confirmation View */
|
||||||
))}
|
<div className="space-y-6">
|
||||||
</select>
|
<div className="text-center">
|
||||||
)}
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
Install Script Confirmation
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Do you want to install "{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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-end space-x-3">
|
<div className="flex justify-end space-x-3">
|
||||||
<button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
variant="outline"
|
||||||
|
size="default"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={handleExecute}
|
onClick={handleExecute}
|
||||||
disabled={selectedMode === 'ssh' && !selectedServer}
|
variant="default"
|
||||||
className={`px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
|
size="default"
|
||||||
selectedMode === 'ssh' && !selectedServer
|
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
|
||||||
: 'bg-blue-600 hover:bg-blue-700'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'}
|
Install
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Multiple Servers Selection View */
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
Select server to execute "{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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||||
|
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -16,13 +19,15 @@ interface FilterBarProps {
|
|||||||
totalScripts: number;
|
totalScripts: number;
|
||||||
filteredCount: number;
|
filteredCount: number;
|
||||||
updatableCount?: number;
|
updatableCount?: number;
|
||||||
|
saveFiltersEnabled?: boolean;
|
||||||
|
isLoadingFilters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCRIPT_TYPES = [
|
const SCRIPT_TYPES = [
|
||||||
{ value: "ct", label: "LXC Container", icon: "📦" },
|
{ value: "ct", label: "LXC Container", Icon: Package },
|
||||||
{ value: "vm", label: "Virtual Machine", icon: "💻" },
|
{ value: "vm", label: "Virtual Machine", Icon: Monitor },
|
||||||
{ value: "addon", label: "Add-on", icon: "🔧" },
|
{ value: "addon", label: "Add-on", Icon: Wrench },
|
||||||
{ value: "pve", label: "PVE Host", icon: "🖥️" },
|
{ value: "pve", label: "PVE Host", Icon: Server },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function FilterBar({
|
export function FilterBar({
|
||||||
@@ -31,8 +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 });
|
||||||
@@ -74,13 +82,32 @@ export function FilterBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoadingFilters && (
|
||||||
|
<div className="mb-4 flex items-center justify-center py-2">
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||||
|
<span>Loading saved filters...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Filter 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-gray-400 dark:text-gray-500"
|
className="h-5 w-5 text-muted-foreground"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -98,12 +125,14 @@ export function FilterBar({
|
|||||||
placeholder="Search scripts..."
|
placeholder="Search scripts..."
|
||||||
value={filters.searchQuery}
|
value={filters.searchQuery}
|
||||||
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
|
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
|
||||||
className="block w-full rounded-lg border border-gray-300 bg-white py-3 pr-10 pl-10 text-sm leading-5 text-gray-900 placeholder-gray-500 focus:border-blue-500 focus:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:placeholder-gray-300 dark:focus:ring-blue-400"
|
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{filters.searchQuery && (
|
{filters.searchQuery && (
|
||||||
<button
|
<Button
|
||||||
onClick={() => updateFilters({ searchQuery: "" })}
|
onClick={() => updateFilters({ searchQuery: "" })}
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
@@ -118,15 +147,15 @@ export function FilterBar({
|
|||||||
d="M6 18L18 6M6 6l12 12"
|
d="M6 18L18 6M6 6l12 12"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Buttons */}
|
{/* Filter Buttons */}
|
||||||
<div className="mb-4 flex flex-wrap gap-3">
|
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
||||||
{/* Updateable Filter */}
|
{/* Updateable Filter */}
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next =
|
const next =
|
||||||
filters.showUpdatable === null
|
filters.showUpdatable === null
|
||||||
@@ -136,27 +165,33 @@ export function FilterBar({
|
|||||||
: null;
|
: null;
|
||||||
updateFilters({ showUpdatable: next });
|
updateFilters({ showUpdatable: next });
|
||||||
}}
|
}}
|
||||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||||
filters.showUpdatable === null
|
filters.showUpdatable === null
|
||||||
? "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
: filters.showUpdatable === true
|
: filters.showUpdatable === true
|
||||||
? "border border-green-300 bg-green-100 text-green-800 dark:border-green-700 dark:bg-green-900/50 dark:text-green-200"
|
? "border border-green-500/20 bg-green-500/10 text-green-400"
|
||||||
: "border border-red-300 bg-red-100 text-red-800 dark:border-red-700 dark:bg-red-900/50 dark:text-red-200"
|
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getUpdatableButtonText()}
|
<RefreshCw className="h-4 w-4" />
|
||||||
</button>
|
<span>{getUpdatableButtonText()}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Type Dropdown */}
|
{/* Type Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative w-full sm:w-auto">
|
||||||
<button
|
<Button
|
||||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className={`w-full flex items-center justify-center space-x-2 ${
|
||||||
filters.selectedTypes.length === 0
|
filters.selectedTypes.length === 0
|
||||||
? "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
: "border border-cyan-300 bg-cyan-100 text-cyan-800 dark:border-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-200"
|
: "border border-primary/20 bg-primary/10 text-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
<span>{getTypeButtonText()}</span>
|
<span>{getTypeButtonText()}</span>
|
||||||
<svg
|
<svg
|
||||||
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
||||||
@@ -171,15 +206,17 @@ export function FilterBar({
|
|||||||
d="M19 9l-7 7-7-7"
|
d="M19 9l-7 7-7-7"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{isTypeDropdownOpen && (
|
{isTypeDropdownOpen && (
|
||||||
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800">
|
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{SCRIPT_TYPES.map((type) => (
|
{SCRIPT_TYPES.map((type) => {
|
||||||
|
const IconComponent = type.Icon;
|
||||||
|
return (
|
||||||
<label
|
<label
|
||||||
key={type.value}
|
key={type.value}
|
||||||
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700"
|
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -200,52 +237,104 @@ export function FilterBar({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
|
className="rounded border-input text-primary focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<span className="text-lg">{type.icon}</span>
|
<IconComponent className="h-4 w-4" />
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
<span className="text-sm text-muted-foreground">
|
||||||
{type.label}
|
{type.label}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-gray-200 p-2 dark:border-gray-700">
|
<div className="border-t border-border p-2">
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateFilters({ selectedTypes: [] });
|
updateFilters({ selectedTypes: [] });
|
||||||
setIsTypeDropdownOpen(false);
|
setIsTypeDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className="w-full rounded-md px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
Clear all
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort By Dropdown */}
|
||||||
|
<div className="relative w-full sm:w-auto">
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{filters.sortBy === "name" ? (
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isSortDropdownOpen && (
|
||||||
|
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
|
||||||
|
<div className="p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
updateFilters({ sortBy: "name" });
|
||||||
|
setIsSortDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||||
|
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span className="text-sm">By Name</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
updateFilters({ sortBy: "created" });
|
||||||
|
setIsSortDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||||
|
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span className="text-sm">By Created Date</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Options */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{/* Sort By Dropdown */}
|
|
||||||
<select
|
|
||||||
value={filters.sortBy}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateFilters({ sortBy: e.target.value as "name" | "created" })
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:focus:ring-blue-400"
|
|
||||||
>
|
|
||||||
<option value="name">📝 By Name</option>
|
|
||||||
<option value="created">📅 By Created Date</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Sort Order Button */}
|
{/* Sort Order Button */}
|
||||||
<button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateFilters({
|
updateFilters({
|
||||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="flex items-center space-x-1 rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
{filters.sortOrder === "asc" ? (
|
{filters.sortOrder === "asc" ? (
|
||||||
<>
|
<>
|
||||||
@@ -286,20 +375,20 @@ export function FilterBar({
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Summary and Clear All */}
|
{/* Filter Summary and Clear All */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
{filteredCount === totalScripts ? (
|
{filteredCount === totalScripts ? (
|
||||||
<span>Showing all {totalScripts} scripts</span>
|
<span>Showing all {totalScripts} scripts</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{filteredCount} of {totalScripts} scripts{" "}
|
{filteredCount} of {totalScripts} scripts{" "}
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<span className="font-medium text-blue-600 dark:text-blue-400">
|
<span className="font-medium text-blue-600">
|
||||||
(filtered)
|
(filtered)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -307,10 +396,23 @@ export function FilterBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Persistence Status */}
|
||||||
|
{!isLoadingFilters && saveFiltersEnabled && (
|
||||||
|
<div className="flex items-center space-x-1 text-xs text-green-600">
|
||||||
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>Filters are being saved automatically</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<button
|
<Button
|
||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
className="flex items-center space-x-1 rounded-md px-3 py-1 text-sm text-red-600 transition-colors hover:bg-red-50 hover:text-red-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:text-red-300"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800 w-full sm:w-auto justify-center sm:justify-start"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -326,15 +428,18 @@ export function FilterBar({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Clear all filters</span>
|
<span>Clear all filters</span>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Click outside to close dropdown */}
|
{/* Click outside to close dropdowns */}
|
||||||
{isTypeDropdownOpen && (
|
{(isTypeDropdownOpen || isSortDropdownOpen) && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-0"
|
className="fixed inset-0 z-0"
|
||||||
onClick={() => setIsTypeDropdownOpen(false)}
|
onClick={() => {
|
||||||
|
setIsTypeDropdownOpen(false);
|
||||||
|
setIsSortDropdownOpen(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
688
src/app/_components/HelpModal.tsx
Normal file
688
src/app/_components/HelpModal.tsx
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
'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' | 'lxc-settings' | '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: 'lxc-settings' as HelpSection, label: 'LXC Settings', icon: Settings },
|
||||||
|
{ 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'lxc-settings':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-4">LXC Settings</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Edit LXC container configuration files directly from the installed scripts interface. This feature allows you to modify container settings without manually accessing the Proxmox VE server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Overview</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
The LXC Settings modal provides a user-friendly interface to edit container configuration files. It parses common settings into editable fields while preserving advanced configurations.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>Common Settings:</strong> Edit basic container parameters like cores, memory, network, and storage</li>
|
||||||
|
<li>• <strong>Advanced Settings:</strong> Raw text editing for lxc.* entries and other advanced configurations</li>
|
||||||
|
<li>• <strong>Database Caching:</strong> Configurations are cached locally for faster access</li>
|
||||||
|
<li>• <strong>Change Detection:</strong> Warns when cached config differs from server version</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Common Settings Tab</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-sm text-foreground mb-1">Basic Configuration</h5>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>Architecture:</strong> Container architecture (usually amd64)</li>
|
||||||
|
<li>• <strong>Cores:</strong> Number of CPU cores allocated to the container</li>
|
||||||
|
<li>• <strong>Memory:</strong> RAM allocation in megabytes</li>
|
||||||
|
<li>• <strong>Swap:</strong> Swap space allocation in megabytes</li>
|
||||||
|
<li>• <strong>Hostname:</strong> Container hostname</li>
|
||||||
|
<li>• <strong>OS Type:</strong> Operating system type (e.g., debian, ubuntu)</li>
|
||||||
|
<li>• <strong>Start on Boot:</strong> Whether to start container automatically on host boot</li>
|
||||||
|
<li>• <strong>Unprivileged:</strong> Whether the container runs in unprivileged mode</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-sm text-foreground mb-1">Network Configuration</h5>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>IP Configuration:</strong> Choose between DHCP or static IP assignment</li>
|
||||||
|
<li>• <strong>IP Address:</strong> Static IP with CIDR notation (e.g., 10.10.10.164/24)</li>
|
||||||
|
<li>• <strong>Gateway:</strong> Network gateway for static IP configuration</li>
|
||||||
|
<li>• <strong>Bridge:</strong> Network bridge interface (usually vmbr0)</li>
|
||||||
|
<li>• <strong>MAC Address:</strong> Hardware address for the network interface</li>
|
||||||
|
<li>• <strong>VLAN Tag:</strong> Optional VLAN tag for network segmentation</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-sm text-foreground mb-1">Storage & Features</h5>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>Root Filesystem:</strong> Storage location and disk identifier</li>
|
||||||
|
<li>• <strong>Size:</strong> Disk size allocation (e.g., 4G, 8G)</li>
|
||||||
|
<li>• <strong>Features:</strong> Container capabilities (keyctl, nesting, fuse)</li>
|
||||||
|
<li>• <strong>Tags:</strong> Comma-separated tags for organization</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Advanced Settings Tab</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
The Advanced Settings tab provides raw text editing for configurations not covered in the Common Settings tab.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>lxc.* entries:</strong> Low-level LXC configuration options</li>
|
||||||
|
<li>• <strong>Comments:</strong> Configuration file comments and documentation</li>
|
||||||
|
<li>• <strong>Custom settings:</strong> Any other configuration parameters</li>
|
||||||
|
<li>• <strong>Preservation:</strong> All content is preserved when switching between tabs</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Saving Changes</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
To save configuration changes, you must type the container ID exactly as shown to confirm your changes.
|
||||||
|
</p>
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
|
||||||
|
<h5 className="font-medium text-yellow-800 dark:text-yellow-200 mb-2">⚠️ Important Warnings</h5>
|
||||||
|
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
|
||||||
|
<li>• Modifying LXC configuration can break your container</li>
|
||||||
|
<li>• Some changes may require container restart to take effect</li>
|
||||||
|
<li>• Always backup your configuration before making changes</li>
|
||||||
|
<li>• Test changes in a non-production environment first</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Sync from Server</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
The "Sync from Server" button allows you to refresh the configuration from the actual server file, useful when:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Configuration was modified outside of this interface</li>
|
||||||
|
<li>• You want to discard local changes and get the latest server version</li>
|
||||||
|
<li>• The warning banner indicates the cached config differs from server</li>
|
||||||
|
<li>• You want to ensure you're working with the most current configuration</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Database Caching</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
LXC configurations are cached in the database for improved performance and offline access.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>Automatic caching:</strong> Configs are cached during auto-detection and after saves</li>
|
||||||
|
<li>• <strong>Cache expiration:</strong> Cached configs expire after 5 minutes for freshness</li>
|
||||||
|
<li>• <strong>Change detection:</strong> Hash comparison detects external modifications</li>
|
||||||
|
<li>• <strong>Manual sync:</strong> Always available via the "Sync from Server" button</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
625
src/app/_components/LXCSettingsModal.tsx
Normal file
625
src/app/_components/LXCSettingsModal.tsx
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
|
import { LoadingModal } from './LoadingModal';
|
||||||
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
|
import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface InstalledScript {
|
||||||
|
id: number;
|
||||||
|
script_name: 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 LXCSettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
script: InstalledScript | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSettingsModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<string>('common');
|
||||||
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [forceSync] = useState(false);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<any>({
|
||||||
|
arch: '',
|
||||||
|
cores: 0,
|
||||||
|
memory: 0,
|
||||||
|
hostname: '',
|
||||||
|
swap: 0,
|
||||||
|
onboot: false,
|
||||||
|
ostype: '',
|
||||||
|
unprivileged: false,
|
||||||
|
net_name: '',
|
||||||
|
net_bridge: '',
|
||||||
|
net_hwaddr: '',
|
||||||
|
net_ip_type: 'dhcp',
|
||||||
|
net_ip: '',
|
||||||
|
net_gateway: '',
|
||||||
|
net_type: '',
|
||||||
|
net_vlan: 0,
|
||||||
|
rootfs_storage: '',
|
||||||
|
rootfs_size: '',
|
||||||
|
feature_keyctl: false,
|
||||||
|
feature_nesting: false,
|
||||||
|
feature_fuse: false,
|
||||||
|
feature_mount: '',
|
||||||
|
tags: '',
|
||||||
|
advanced_config: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// tRPC hooks
|
||||||
|
const { data: configData, isLoading } = api.installedScripts.getLXCConfig.useQuery(
|
||||||
|
{ scriptId: script?.id ?? 0, forceSync },
|
||||||
|
{ enabled: !!script && isOpen }
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveMutation = api.installedScripts.saveLXCConfig.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMessage('LXC configuration saved successfully');
|
||||||
|
setHasChanges(false);
|
||||||
|
setShowConfirmation(false);
|
||||||
|
onSave();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(`Failed to save configuration: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncMutation = api.installedScripts.syncLXCConfig.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
populateFormData(result);
|
||||||
|
setSuccessMessage('Configuration synced from server successfully');
|
||||||
|
setHasChanges(false);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(`Failed to sync configuration: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate form data helper
|
||||||
|
const populateFormData = (result: any) => {
|
||||||
|
if (!result?.success) return;
|
||||||
|
const config = result.config;
|
||||||
|
setFormData({
|
||||||
|
arch: config.arch ?? '',
|
||||||
|
cores: config.cores ?? 0,
|
||||||
|
memory: config.memory ?? 0,
|
||||||
|
hostname: config.hostname ?? '',
|
||||||
|
swap: config.swap ?? 0,
|
||||||
|
onboot: config.onboot === 1,
|
||||||
|
ostype: config.ostype ?? '',
|
||||||
|
unprivileged: config.unprivileged === 1,
|
||||||
|
net_name: config.net_name ?? '',
|
||||||
|
net_bridge: config.net_bridge ?? '',
|
||||||
|
net_hwaddr: config.net_hwaddr ?? '',
|
||||||
|
net_ip_type: config.net_ip_type ?? 'dhcp',
|
||||||
|
net_ip: config.net_ip ?? '',
|
||||||
|
net_gateway: config.net_gateway ?? '',
|
||||||
|
net_type: config.net_type ?? '',
|
||||||
|
net_vlan: config.net_vlan ?? 0,
|
||||||
|
rootfs_storage: config.rootfs_storage ?? '',
|
||||||
|
rootfs_size: config.rootfs_size ?? '',
|
||||||
|
feature_keyctl: config.feature_keyctl === 1,
|
||||||
|
feature_nesting: config.feature_nesting === 1,
|
||||||
|
feature_fuse: config.feature_fuse === 1,
|
||||||
|
feature_mount: config.feature_mount ?? '',
|
||||||
|
tags: config.tags ?? '',
|
||||||
|
advanced_config: config.advanced_config ?? ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load config when data arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (configData?.success) {
|
||||||
|
populateFormData(configData);
|
||||||
|
setHasChanges(false);
|
||||||
|
} else if (configData && !configData.success) {
|
||||||
|
setError(String(configData.error ?? 'Failed to load configuration'));
|
||||||
|
}
|
||||||
|
}, [configData]);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: any): void => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncFromServer = () => {
|
||||||
|
if (!script) return;
|
||||||
|
setError(null);
|
||||||
|
syncMutation.mutate({ scriptId: script.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setShowConfirmation(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSave = () => {
|
||||||
|
if (!script) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
saveMutation.mutate({
|
||||||
|
scriptId: script.id,
|
||||||
|
config: {
|
||||||
|
...formData,
|
||||||
|
onboot: formData.onboot ? 1 : 0,
|
||||||
|
unprivileged: formData.unprivileged ? 1 : 0,
|
||||||
|
feature_keyctl: formData.feature_keyctl ? 1 : 0,
|
||||||
|
feature_nesting: formData.feature_nesting ? 1 : 0,
|
||||||
|
feature_fuse: formData.feature_fuse ? 1 : 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !script) 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-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">LXC Settings</h2>
|
||||||
|
<Badge variant="outline">{script.container_id}</Badge>
|
||||||
|
<ContextualHelpIcon section="lxc-settings" tooltip="Help with LXC Settings" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSyncFromServer}
|
||||||
|
disabled={syncMutation.isPending ?? isLoading ?? saveMutation.isPending}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
Sync from Server
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning Banner */}
|
||||||
|
{configData?.has_changes && (
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-800 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
Configuration Mismatch Detected
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||||
|
The cached configuration differs from the server. Click "Sync from Server" to get the latest version.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-950/20 border-b border-green-200 dark:border-green-800 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-200">{successMessage}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSuccessMessage(null)}
|
||||||
|
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-950/20 border-b border-red-200 dark:border-red-800 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-red-800 dark:text-red-200">Error</p>
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-red-600 dark:text-red-500 hover:text-red-700 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="border-b border-border mb-6">
|
||||||
|
<nav className="flex space-x-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('common')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'common'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Common Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('advanced')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'advanced'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Advanced Settings
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Common Settings Tab */}
|
||||||
|
{activeTab === 'common' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Basic Configuration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Basic Configuration</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="arch" className="block text-sm font-medium text-foreground">Architecture *</label>
|
||||||
|
<Input
|
||||||
|
id="arch"
|
||||||
|
value={formData.arch}
|
||||||
|
onChange={(e) => handleInputChange('arch', e.target.value)}
|
||||||
|
placeholder="amd64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="cores" className="block text-sm font-medium text-foreground">Cores *</label>
|
||||||
|
<Input
|
||||||
|
id="cores"
|
||||||
|
type="number"
|
||||||
|
value={formData.cores}
|
||||||
|
onChange={(e) => handleInputChange('cores', parseInt(e.target.value) || 0)}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="memory" className="block text-sm font-medium text-foreground">Memory (MB) *</label>
|
||||||
|
<Input
|
||||||
|
id="memory"
|
||||||
|
type="number"
|
||||||
|
value={formData.memory}
|
||||||
|
onChange={(e) => handleInputChange('memory', parseInt(e.target.value) || 0)}
|
||||||
|
min="128"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="swap" className="block text-sm font-medium text-foreground">Swap (MB)</label>
|
||||||
|
<Input
|
||||||
|
id="swap"
|
||||||
|
type="number"
|
||||||
|
value={formData.swap}
|
||||||
|
onChange={(e) => handleInputChange('swap', parseInt(e.target.value) || 0)}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="hostname" className="block text-sm font-medium text-foreground">Hostname *</label>
|
||||||
|
<Input
|
||||||
|
id="hostname"
|
||||||
|
value={formData.hostname}
|
||||||
|
onChange={(e) => handleInputChange('hostname', e.target.value)}
|
||||||
|
placeholder="container-hostname"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="ostype" className="block text-sm font-medium text-foreground">OS Type *</label>
|
||||||
|
<Input
|
||||||
|
id="ostype"
|
||||||
|
value={formData.ostype}
|
||||||
|
onChange={(e) => handleInputChange('ostype', e.target.value)}
|
||||||
|
placeholder="debian"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="onboot"
|
||||||
|
checked={formData.onboot}
|
||||||
|
onChange={(e) => handleInputChange('onboot', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="onboot" className="text-sm font-medium text-foreground">Start on Boot</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="unprivileged"
|
||||||
|
checked={formData.unprivileged}
|
||||||
|
onChange={(e) => handleInputChange('unprivileged', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="unprivileged" className="text-sm font-medium text-foreground">Unprivileged Container</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Configuration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Network Configuration</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_name" className="block text-sm font-medium text-foreground">Interface Name</label>
|
||||||
|
<Input
|
||||||
|
id="net_name"
|
||||||
|
value={formData.net_name}
|
||||||
|
onChange={(e) => handleInputChange('net_name', e.target.value)}
|
||||||
|
placeholder="eth0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_bridge" className="block text-sm font-medium text-foreground">Bridge</label>
|
||||||
|
<Input
|
||||||
|
id="net_bridge"
|
||||||
|
value={formData.net_bridge}
|
||||||
|
onChange={(e) => handleInputChange('net_bridge', e.target.value)}
|
||||||
|
placeholder="vmbr0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_hwaddr" className="block text-sm font-medium text-foreground">MAC Address</label>
|
||||||
|
<Input
|
||||||
|
id="net_hwaddr"
|
||||||
|
value={formData.net_hwaddr}
|
||||||
|
onChange={(e) => handleInputChange('net_hwaddr', e.target.value)}
|
||||||
|
placeholder="BC:24:11:2D:2D:AB"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_type" className="block text-sm font-medium text-foreground">Type</label>
|
||||||
|
<Input
|
||||||
|
id="net_type"
|
||||||
|
value={formData.net_type}
|
||||||
|
onChange={(e) => handleInputChange('net_type', e.target.value)}
|
||||||
|
placeholder="veth"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_ip_type" className="block text-sm font-medium text-foreground">IP Configuration</label>
|
||||||
|
<select
|
||||||
|
id="net_ip_type"
|
||||||
|
value={formData.net_ip_type}
|
||||||
|
onChange={(e) => handleInputChange('net_ip_type', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-input bg-background rounded-md"
|
||||||
|
>
|
||||||
|
<option value="dhcp">DHCP</option>
|
||||||
|
<option value="static">Static IP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{formData.net_ip_type === 'static' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_ip" className="block text-sm font-medium text-foreground">IP Address with CIDR *</label>
|
||||||
|
<Input
|
||||||
|
id="net_ip"
|
||||||
|
value={formData.net_ip}
|
||||||
|
onChange={(e) => handleInputChange('net_ip', e.target.value)}
|
||||||
|
placeholder="10.10.10.164/24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_gateway" className="block text-sm font-medium text-foreground">Gateway</label>
|
||||||
|
<Input
|
||||||
|
id="net_gateway"
|
||||||
|
value={formData.net_gateway}
|
||||||
|
onChange={(e) => handleInputChange('net_gateway', e.target.value)}
|
||||||
|
placeholder="10.10.10.254"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_vlan" className="block text-sm font-medium text-foreground">VLAN Tag</label>
|
||||||
|
<Input
|
||||||
|
id="net_vlan"
|
||||||
|
type="number"
|
||||||
|
value={formData.net_vlan}
|
||||||
|
onChange={(e) => handleInputChange('net_vlan', parseInt(e.target.value) || 0)}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Storage */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Storage</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="rootfs_storage" className="block text-sm font-medium text-foreground">Root Filesystem *</label>
|
||||||
|
<Input
|
||||||
|
id="rootfs_storage"
|
||||||
|
value={formData.rootfs_storage}
|
||||||
|
onChange={(e) => handleInputChange('rootfs_storage', e.target.value)}
|
||||||
|
placeholder="PROX2-STORAGE2:vm-109-disk-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="rootfs_size" className="block text-sm font-medium text-foreground">Size</label>
|
||||||
|
<Input
|
||||||
|
id="rootfs_size"
|
||||||
|
value={formData.rootfs_size}
|
||||||
|
onChange={(e) => handleInputChange('rootfs_size', e.target.value)}
|
||||||
|
placeholder="4G"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Features</h3>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="feature_keyctl"
|
||||||
|
checked={formData.feature_keyctl}
|
||||||
|
onChange={(e) => handleInputChange('feature_keyctl', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="feature_keyctl" className="text-sm font-medium text-foreground">Keyctl</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="feature_nesting"
|
||||||
|
checked={formData.feature_nesting}
|
||||||
|
onChange={(e) => handleInputChange('feature_nesting', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="feature_nesting" className="text-sm font-medium text-foreground">Nesting</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="feature_fuse"
|
||||||
|
checked={formData.feature_fuse}
|
||||||
|
onChange={(e) => handleInputChange('feature_fuse', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="feature_fuse" className="text-sm font-medium text-foreground">FUSE</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="feature_mount" className="block text-sm font-medium text-foreground">Additional Mount Features</label>
|
||||||
|
<Input
|
||||||
|
id="feature_mount"
|
||||||
|
value={formData.feature_mount}
|
||||||
|
onChange={(e) => handleInputChange('feature_mount', e.target.value)}
|
||||||
|
placeholder="Additional features (comma-separated)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Tags</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="tags" className="block text-sm font-medium text-foreground">Tags</label>
|
||||||
|
<Input
|
||||||
|
id="tags"
|
||||||
|
value={formData.tags}
|
||||||
|
onChange={(e) => handleInputChange('tags', e.target.value)}
|
||||||
|
placeholder="community-script;pve-scripts-local"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advanced Settings Tab */}
|
||||||
|
{activeTab === 'advanced' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="advanced_config" className="block text-sm font-medium text-foreground">Advanced Configuration</label>
|
||||||
|
<textarea
|
||||||
|
id="advanced_config"
|
||||||
|
value={formData.advanced_config}
|
||||||
|
onChange={(e) => handleInputChange('advanced_config', e.target.value)}
|
||||||
|
placeholder="lxc.* entries, comments, and other advanced settings..."
|
||||||
|
className="w-full min-h-[400px] px-3 py-2 border border-input bg-background rounded-md font-mono text-sm resize-vertical"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This section contains lxc.* entries, comments, and other advanced settings that are not covered in the Common Settings tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end p-4 sm:p-6 border-t border-border bg-muted/30">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveMutation.isPending || !hasChanges}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? 'Saving...' : 'Save Configuration'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
<ConfirmationModal
|
||||||
|
isOpen={showConfirmation}
|
||||||
|
onClose={() => {
|
||||||
|
setShowConfirmation(false);
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmSave}
|
||||||
|
title="Confirm LXC Configuration Changes"
|
||||||
|
message="Modifying LXC configuration can break your container and may require manual recovery. Ensure you understand these changes before proceeding. The container may need to be restarted for changes to take effect."
|
||||||
|
variant="danger"
|
||||||
|
confirmText={script.container_id ?? ''}
|
||||||
|
confirmButtonText="Save Configuration"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading Modal */}
|
||||||
|
<LoadingModal
|
||||||
|
isOpen={isLoading}
|
||||||
|
action="Loading LXC configuration..."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { api } from '~/trpc/react';
|
|
||||||
|
|
||||||
interface ProxmoxCheckProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProxmoxCheck({ children }: ProxmoxCheckProps) {
|
|
||||||
const [isChecking, setIsChecking] = useState(true);
|
|
||||||
const [isProxmoxVE, setIsProxmoxVE] = useState<boolean | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data: proxmoxData, isLoading } = api.scripts.checkProxmoxVE.useQuery();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (proxmoxData && typeof proxmoxData === 'object' && 'success' in proxmoxData) {
|
|
||||||
setIsChecking(false);
|
|
||||||
if (proxmoxData.success) {
|
|
||||||
const isProxmox = 'isProxmoxVE' in proxmoxData ? proxmoxData.isProxmoxVE as boolean : false;
|
|
||||||
setIsProxmoxVE(isProxmox);
|
|
||||||
if (!isProxmox) {
|
|
||||||
setError('This application can only run on a Proxmox VE Host');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const errorMsg = 'error' in proxmoxData ? proxmoxData.error as string : 'Failed to check Proxmox VE status';
|
|
||||||
setError(errorMsg);
|
|
||||||
setIsProxmoxVE(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [proxmoxData]);
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
if (isChecking || isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">Checking system requirements...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show error if not running on Proxmox VE
|
|
||||||
if (!isProxmoxVE || error) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
|
||||||
<div className="max-w-md mx-auto text-center">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-8">
|
|
||||||
<div className="flex items-center justify-center w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full">
|
|
||||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 19.5c-.77.833.192 2.5 1.732 2.5z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-red-800 mb-2">
|
|
||||||
System Requirements Not Met
|
|
||||||
</h1>
|
|
||||||
<p className="text-red-700 mb-4">
|
|
||||||
{error ?? 'This application can only run on a Proxmox VE Host'}
|
|
||||||
</p>
|
|
||||||
<div className="text-sm text-red-600 bg-red-100 rounded-lg p-4">
|
|
||||||
<p className="font-medium mb-2">To use this application, you need:</p>
|
|
||||||
<ul className="text-left space-y-1">
|
|
||||||
<li>• A Proxmox VE host system</li>
|
|
||||||
<li>• The <code className="bg-red-200 px-1 rounded">pveversion</code> command must be available</li>
|
|
||||||
<li>• Proper permissions to execute system commands</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="mt-6 px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
Retry Check
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If running on Proxmox VE, render the children
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
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 };
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
|
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);
|
||||||
@@ -39,36 +41,37 @@ export function ResyncButton() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-300 font-medium">
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
Sync scripts with ProxmoxVE repo
|
Sync scripts with ProxmoxVE repo
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
onClick={handleResync}
|
onClick={handleResync}
|
||||||
disabled={isResyncing}
|
disabled={isResyncing}
|
||||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
variant="outline"
|
||||||
isResyncing
|
size="default"
|
||||||
? 'bg-gray-400 dark:bg-gray-600 text-white cursor-not-allowed'
|
className="inline-flex items-center"
|
||||||
: 'bg-blue-600 dark:bg-blue-700 text-white hover:bg-blue-700 dark:hover:bg-blue-600'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isResyncing ? (
|
{isResyncing ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||||
<span>Syncing...</span>
|
<span>Syncing...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Sync Json Files</span>
|
<span>Sync Json Files</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
|
<ContextualHelpIcon section="sync-button" tooltip="Help with Sync Button" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{lastSync && (
|
{lastSync && (
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-muted-foreground">
|
||||||
Last sync: {lastSync.toLocaleTimeString()}
|
Last sync: {lastSync.toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -77,8 +80,8 @@ export function ResyncButton() {
|
|||||||
{syncMessage && (
|
{syncMessage && (
|
||||||
<div className={`text-sm px-3 py-1 rounded-lg ${
|
<div className={`text-sm px-3 py-1 rounded-lg ${
|
||||||
syncMessage.includes('Error') || syncMessage.includes('Failed')
|
syncMessage.includes('Error') || syncMessage.includes('Failed')
|
||||||
? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300'
|
? 'bg-red-100 text-destructive'
|
||||||
: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300'
|
: 'bg-green-100 text-green-700'
|
||||||
}`}>
|
}`}>
|
||||||
{syncMessage}
|
{syncMessage}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg dark:hover:shadow-xl transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 h-full flex flex-col"
|
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
|
||||||
onClick={() => onClick(script)}
|
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">
|
||||||
@@ -36,20 +65,20 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
|||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
|
||||||
<span className="text-gray-500 dark:text-gray-400 text-lg font-semibold">
|
<span className="text-muted-foreground text-lg font-semibold">
|
||||||
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate">
|
<h3 className="text-lg font-semibold text-foreground truncate">
|
||||||
{script.name || 'Unnamed Script'}
|
{script.name || 'Unnamed Script'}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
{/* 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>
|
||||||
@@ -60,7 +89,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
|||||||
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
|
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
|
||||||
}`}></div>
|
}`}></div>
|
||||||
<span className={`text-xs font-medium ${
|
<span className={`text-xs font-medium ${
|
||||||
script.isDownloaded ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'
|
script.isDownloaded ? 'text-green-700' : 'text-destructive'
|
||||||
}`}>
|
}`}>
|
||||||
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
||||||
</span>
|
</span>
|
||||||
@@ -70,7 +99,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-sm line-clamp-3 mb-4 flex-1">
|
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
|
||||||
{script.description || 'No description available'}
|
{script.description || 'No description available'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -81,7 +110,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
|||||||
href={script.website}
|
href={script.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm font-medium flex items-center space-x-1"
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span>Website</span>
|
<span>Website</span>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { DiffViewer } from "./DiffViewer";
|
|||||||
import { TextViewer } from "./TextViewer";
|
import { TextViewer } from "./TextViewer";
|
||||||
import { ExecutionModeModal } from "./ExecutionModeModal";
|
import { ExecutionModeModal } from "./ExecutionModeModal";
|
||||||
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
|
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
interface ScriptDetailModalProps {
|
interface ScriptDetailModalProps {
|
||||||
script: Script | null;
|
script: Script | null;
|
||||||
@@ -62,20 +63,20 @@ export function ScriptDetailModal({
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
const message =
|
const message =
|
||||||
"message" in data ? data.message : "Script loaded successfully";
|
"message" in data ? data.message : "Script loaded successfully";
|
||||||
setLoadMessage(`✅ ${message}`);
|
setLoadMessage(`[SUCCESS] ${message}`);
|
||||||
// Refetch script files status and comparison data to update the UI
|
// Refetch script files status and comparison data to update the UI
|
||||||
void refetchScriptFiles();
|
void refetchScriptFiles();
|
||||||
void refetchComparison();
|
void refetchComparison();
|
||||||
} else {
|
} else {
|
||||||
const error = "error" in data ? data.error : "Failed to load script";
|
const error = "error" in data ? data.error : "Failed to load script";
|
||||||
setLoadMessage(`❌ ${error}`);
|
setLoadMessage(`[ERROR] ${error}`);
|
||||||
}
|
}
|
||||||
// Clear message after 5 seconds
|
// Clear message after 5 seconds
|
||||||
setTimeout(() => setLoadMessage(null), 5000);
|
setTimeout(() => setLoadMessage(null), 5000);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setLoadMessage(`❌ Error: ${error.message}`);
|
setLoadMessage(`[ERROR] ${error.message}`);
|
||||||
setTimeout(() => setLoadMessage(null), 5000);
|
setTimeout(() => setLoadMessage(null), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -119,9 +120,6 @@ export function ScriptDetailModal({
|
|||||||
// Pass execution mode and server info to the parent
|
// Pass execution mode and server info to the parent
|
||||||
onInstallScript(scriptPath, scriptName, mode, server);
|
onInstallScript(scriptPath, scriptName, mode, server);
|
||||||
|
|
||||||
// Scroll to top of the page to see the terminal
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
|
|
||||||
onClose(); // Close the modal when starting installation
|
onClose(); // Close the modal when starting installation
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -132,48 +130,75 @@ export function ScriptDetailModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black p-4"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
>
|
>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto">
|
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
|
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
{script.logo && !imageError ? (
|
{script.logo && !imageError ? (
|
||||||
<Image
|
<Image
|
||||||
src={script.logo}
|
src={script.logo}
|
||||||
alt={`${script.name} logo`}
|
alt={`${script.name} logo`}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className="h-16 w-16 rounded-lg object-contain"
|
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-gray-200 dark:bg-gray-700">
|
<div className="flex h-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0">
|
||||||
<span className="text-2xl font-semibold text-gray-500 dark:text-gray-400">
|
<span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
|
||||||
{script.name.charAt(0).toUpperCase()}
|
{script.name.charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
|
||||||
{script.name}
|
{script.name}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-1 flex items-center space-x-2">
|
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||||
<TypeBadge type={script.type} />
|
<TypeBadge type={script.type} />
|
||||||
{script.updateable && <UpdateableBadge />}
|
{script.updateable && <UpdateableBadge />}
|
||||||
{script.privileged && <PrivilegedBadge />}
|
{script.privileged && <PrivilegedBadge />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 sm:h-6 sm:w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
|
||||||
{/* Install Button - only show if script files exist */}
|
{/* Install Button - only show if script files exist */}
|
||||||
{scriptFilesData?.success &&
|
{scriptFilesData?.success &&
|
||||||
scriptFilesData.ctExists &&
|
scriptFilesData.ctExists &&
|
||||||
onInstallScript && (
|
onInstallScript && (
|
||||||
<button
|
<Button
|
||||||
onClick={handleInstallScript}
|
onClick={handleInstallScript}
|
||||||
className="flex items-center space-x-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -189,15 +214,17 @@ export function ScriptDetailModal({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Install</span>
|
<span>Install</span>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* View Button - only show if script files exist */}
|
{/* View Button - only show if script files exist */}
|
||||||
{scriptFilesData?.success &&
|
{scriptFilesData?.success &&
|
||||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||||
<button
|
<Button
|
||||||
onClick={handleViewScript}
|
onClick={handleViewScript}
|
||||||
className="flex items-center space-x-2 rounded-lg bg-purple-600 px-4 py-2 font-medium text-white transition-colors hover:bg-purple-700"
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -219,7 +246,7 @@ export function ScriptDetailModal({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>View</span>
|
<span>View</span>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Load/Update Script Button */}
|
{/* Load/Update Script Button */}
|
||||||
@@ -239,7 +266,7 @@ export function ScriptDetailModal({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
isLoading
|
isLoading
|
||||||
? "cursor-not-allowed bg-gray-400 text-white"
|
? "cursor-not-allowed bg-muted text-muted-foreground"
|
||||||
: "bg-green-600 text-white hover:bg-green-700"
|
: "bg-green-600 text-white hover:bg-green-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -273,7 +300,7 @@ export function ScriptDetailModal({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-gray-400 px-4 py-2 font-medium text-white transition-colors"
|
className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-muted px-4 py-2 font-medium text-muted-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -299,7 +326,7 @@ export function ScriptDetailModal({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
isLoading
|
isLoading
|
||||||
? "cursor-not-allowed bg-gray-400 text-white"
|
? "cursor-not-allowed bg-muted text-muted-foreground"
|
||||||
: "bg-orange-600 text-white hover:bg-orange-700"
|
: "bg-orange-600 text-white hover:bg-orange-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -330,37 +357,13 @@ export function ScriptDetailModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Load Message */}
|
{/* Content */}
|
||||||
{loadMessage && (
|
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||||
<div className="mx-6 mb-4 rounded-lg bg-blue-50 p-3 text-sm text-blue-800">
|
|
||||||
{loadMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Script Files Status */}
|
{/* Script Files Status */}
|
||||||
{(scriptFilesLoading || comparisonLoading) && (
|
{(scriptFilesLoading || comparisonLoading) && (
|
||||||
<div className="mx-6 mb-4 rounded-lg bg-blue-50 p-3 text-sm">
|
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||||
<span>Loading script status...</span>
|
<span>Loading script status...</span>
|
||||||
@@ -385,11 +388,11 @@ export function ScriptDetailModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-6 mb-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
<div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-gray-300"}`}
|
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-muted"}`}
|
||||||
></div>
|
></div>
|
||||||
<span>
|
<span>
|
||||||
{scriptType}:{" "}
|
{scriptType}:{" "}
|
||||||
@@ -398,7 +401,7 @@ export function ScriptDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-green-500" : "bg-gray-300"}`}
|
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-green-500" : "bg-muted"}`}
|
||||||
></div>
|
></div>
|
||||||
<span>
|
<span>
|
||||||
Install Script:{" "}
|
Install Script:{" "}
|
||||||
@@ -426,7 +429,7 @@ export function ScriptDetailModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{scriptFilesData.files.length > 0 && (
|
{scriptFilesData.files.length > 0 && (
|
||||||
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
<div className="mt-2 text-xs text-muted-foreground break-words">
|
||||||
Files: {scriptFilesData.files.join(", ")}
|
Files: {scriptFilesData.files.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -434,65 +437,70 @@ export function ScriptDetailModal({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Load Message */}
|
||||||
<div className="space-y-6 p-6">
|
{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-gray-900 dark:text-gray-100">
|
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Description
|
Description
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
<p className="text-sm sm:text-base text-muted-foreground">
|
||||||
{script.description}
|
{script.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Basic Information
|
Basic Information
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
Slug
|
Slug
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="font-mono text-sm text-gray-900 dark:text-gray-100">
|
<dd className="font-mono text-sm text-foreground">
|
||||||
{script.slug}
|
{script.slug}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
Date Created
|
Date Created
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 dark:text-gray-100">
|
<dd className="text-sm text-foreground">
|
||||||
{script.date_created}
|
{script.date_created}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
Categories
|
Categories
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 dark:text-gray-100">
|
<dd className="text-sm text-foreground">
|
||||||
{script.categories.join(", ")}
|
{script.categories.join(", ")}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{script.interface_port && (
|
{script.interface_port && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
Interface Port
|
Interface Port
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 dark:text-gray-100">
|
<dd className="text-sm text-foreground">
|
||||||
{script.interface_port}
|
{script.interface_port}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{script.config_path && (
|
{script.config_path && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
Config Path
|
Config Path
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="font-mono text-sm text-gray-900 dark:text-gray-100">
|
<dd className="font-mono text-sm text-foreground">
|
||||||
{script.config_path}
|
{script.config_path}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -501,13 +509,13 @@ export function ScriptDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Links
|
Links
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
{script.website && (
|
{script.website && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
Website
|
Website
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm">
|
<dd className="text-sm">
|
||||||
@@ -515,7 +523,7 @@ export function ScriptDetailModal({
|
|||||||
href={script.website}
|
href={script.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="break-all text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
className="break-all text-primary hover:text-primary/80"
|
||||||
>
|
>
|
||||||
{script.website}
|
{script.website}
|
||||||
</a>
|
</a>
|
||||||
@@ -524,7 +532,7 @@ export function ScriptDetailModal({
|
|||||||
)}
|
)}
|
||||||
{script.documentation && (
|
{script.documentation && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
Documentation
|
Documentation
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm">
|
<dd className="text-sm">
|
||||||
@@ -532,7 +540,7 @@ export function ScriptDetailModal({
|
|||||||
href={script.documentation}
|
href={script.documentation}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="break-all text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
className="break-all text-primary hover:text-primary/80"
|
||||||
>
|
>
|
||||||
{script.documentation}
|
{script.documentation}
|
||||||
</a>
|
</a>
|
||||||
@@ -548,53 +556,53 @@ export function ScriptDetailModal({
|
|||||||
script.type !== "pve" &&
|
script.type !== "pve" &&
|
||||||
script.type !== "addon" && (
|
script.type !== "addon" && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Install Methods
|
Install Methods
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{script.install_methods.map((method, index) => (
|
{script.install_methods.map((method, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-600 dark:bg-gray-700"
|
className="rounded-lg border border-border bg-card p-3 sm:p-4"
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
|
||||||
<h4 className="font-medium text-gray-900 capitalize dark:text-gray-100">
|
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
|
||||||
{method.type}
|
{method.type}
|
||||||
</h4>
|
</h4>
|
||||||
<span className="font-mono text-sm text-gray-500 dark:text-gray-400">
|
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
|
||||||
{method.script}
|
{method.script}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium text-gray-500 dark:text-gray-400">
|
<dt className="font-medium text-muted-foreground">
|
||||||
CPU
|
CPU
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-gray-900 dark:text-gray-100">
|
<dd className="text-foreground">
|
||||||
{method.resources.cpu} cores
|
{method.resources.cpu} cores
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium text-gray-500 dark:text-gray-400">
|
<dt className="font-medium text-muted-foreground">
|
||||||
RAM
|
RAM
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-gray-900 dark:text-gray-100">
|
<dd className="text-foreground">
|
||||||
{method.resources.ram} MB
|
{method.resources.ram} MB
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium text-gray-500 dark:text-gray-400">
|
<dt className="font-medium text-muted-foreground">
|
||||||
HDD
|
HDD
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-gray-900 dark:text-gray-100">
|
<dd className="text-foreground">
|
||||||
{method.resources.hdd} GB
|
{method.resources.hdd} GB
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium text-gray-500 dark:text-gray-400">
|
<dt className="font-medium text-muted-foreground">
|
||||||
OS
|
OS
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-gray-900 dark:text-gray-100">
|
<dd className="text-foreground">
|
||||||
{method.resources.os} {method.resources.version}
|
{method.resources.os} {method.resources.version}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -609,26 +617,26 @@ export function ScriptDetailModal({
|
|||||||
{(script.default_credentials.username ??
|
{(script.default_credentials.username ??
|
||||||
script.default_credentials.password) && (
|
script.default_credentials.password) && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-gray-900">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Default Credentials
|
Default Credentials
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
{script.default_credentials.username && (
|
{script.default_credentials.username && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500">
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
Username
|
Username
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="font-mono text-sm text-gray-900">
|
<dd className="font-mono text-sm text-foreground">
|
||||||
{script.default_credentials.username}
|
{script.default_credentials.username}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{script.default_credentials.password && (
|
{script.default_credentials.password && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500">
|
<dt className="text-sm font-medium text-muted-foreground">
|
||||||
Password
|
Password
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="font-mono text-sm text-gray-900">
|
<dd className="font-mono text-sm text-foreground">
|
||||||
{script.default_credentials.password}
|
{script.default_credentials.password}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -640,7 +648,7 @@ export function ScriptDetailModal({
|
|||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{script.notes.length > 0 && (
|
{script.notes.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
||||||
Notes
|
Notes
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
@@ -655,10 +663,10 @@ export function ScriptDetailModal({
|
|||||||
key={index}
|
key={index}
|
||||||
className={`rounded-lg p-3 text-sm ${
|
className={`rounded-lg p-3 text-sm ${
|
||||||
noteType === "warning"
|
noteType === "warning"
|
||||||
? "border-l-4 border-yellow-400 bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200"
|
? "border-l-4 border-yellow-400 bg-yellow-500/10 text-yellow-400"
|
||||||
: noteType === "error"
|
: noteType === "error"
|
||||||
? "border-l-4 border-red-400 bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200"
|
? "border-l-4 border-destructive bg-destructive/10 text-destructive"
|
||||||
: "bg-gray-50 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
: "bg-muted text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
|
|||||||
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,12 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { ScriptCard } from './ScriptCard';
|
import { ScriptCard } from './ScriptCard';
|
||||||
|
import { ScriptCardList } from './ScriptCardList';
|
||||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||||
import { CategorySidebar } from './CategorySidebar';
|
import { CategorySidebar } from './CategorySidebar';
|
||||||
import { FilterBar, type FilterState } from './FilterBar';
|
import { FilterBar, type FilterState } from './FilterBar';
|
||||||
|
import { ViewToggle } from './ViewToggle';
|
||||||
|
import { Button } from './ui/button';
|
||||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +21,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,
|
||||||
@@ -25,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 [];
|
||||||
@@ -233,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);
|
||||||
@@ -253,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
|
||||||
@@ -269,7 +542,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
<span className="ml-2 text-gray-600">Loading scripts...</span>
|
<span className="ml-2 text-muted-foreground">Loading scripts...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -282,29 +555,31 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-lg font-medium">Failed to load scripts</p>
|
<p className="text-lg font-medium">Failed to load scripts</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
|
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className="mt-4"
|
||||||
>
|
>
|
||||||
Try Again
|
Try Again
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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-gray-500">
|
<div className="text-muted-foreground">
|
||||||
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-lg font-medium">No scripts found</p>
|
<p className="text-lg font-medium">No scripts found</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
No script files were found in the repository or local directory.
|
No script files were found in the repository or local directory.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,9 +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}
|
||||||
@@ -326,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}
|
||||||
@@ -334,13 +609,172 @@ 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">
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<svg className="h-5 w-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,12 +783,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
placeholder="Search scripts by name..."
|
placeholder="Search scripts by name..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-800 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-gray-100 focus:outline-none focus:placeholder-gray-400 dark:focus:placeholder-gray-300 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 text-sm"
|
className="block w-full pl-10 pr-3 py-3 border border-border rounded-lg leading-5 bg-card placeholder-muted-foreground text-foreground focus:outline-none focus:placeholder-muted-foreground focus:ring-2 focus:ring-ring focus:border-ring text-sm"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => setSearchQuery('')}
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -363,7 +797,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(searchQuery || selectedCategory) && (
|
{(searchQuery || selectedCategory) && (
|
||||||
<div className="text-center mt-2 text-sm text-gray-600">
|
<div className="text-center mt-2 text-sm text-muted-foreground">
|
||||||
{filteredScripts.length === 0 ? (
|
{filteredScripts.length === 0 ? (
|
||||||
<span>No scripts found{searchQuery ? ` matching "${searchQuery}"` : ''}{selectedCategory ? ` in category "${selectedCategory}"` : ''}</span>
|
<span>No scripts found{searchQuery ? ` matching "${searchQuery}"` : ''}{selectedCategory ? ` in category "${selectedCategory}"` : ''}</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -380,35 +814,38 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
{/* Scripts Grid */}
|
{/* Scripts Grid */}
|
||||||
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-gray-500">
|
<div className="text-muted-foreground">
|
||||||
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-lg font-medium">No matching scripts found</p>
|
<p className="text-lg font-medium">No matching scripts found</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Try different filter settings or clear all filters.
|
Try different filter settings or clear all filters.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-2 mt-4">
|
<div className="flex justify-center gap-2 mt-4">
|
||||||
{filters.searchQuery && (
|
{filters.searchQuery && (
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
|
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
variant="default"
|
||||||
|
size="default"
|
||||||
>
|
>
|
||||||
Clear Search
|
Clear Search
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{selectedCategory && (
|
{selectedCategory && (
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleCategorySelect(null)}
|
onClick={() => handleCategorySelect(null)}
|
||||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
variant="secondary"
|
||||||
|
size="default"
|
||||||
>
|
>
|
||||||
Clear Category
|
Clear Category
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
viewMode === 'card' ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
{filteredScripts.map((script, index) => {
|
{filteredScripts.map((script, index) => {
|
||||||
// Add validation to ensure script has required properties
|
// Add validation to ensure script has required properties
|
||||||
@@ -424,10 +861,35 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
key={uniqueKey}
|
key={uniqueKey}
|
||||||
script={script}
|
script={script}
|
||||||
onClick={handleCardClick}
|
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,7 +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 { SSHKeyInput } from './SSHKeyInput';
|
||||||
|
import { PublicKeyModal } from './PublicKeyModal';
|
||||||
|
import { Key } from 'lucide-react';
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
onSubmit: (data: CreateServerData) => void;
|
onSubmit: (data: CreateServerData) => void;
|
||||||
@@ -17,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';
|
||||||
@@ -43,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) => {
|
||||||
@@ -56,26 +104,92 @@ 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: true
|
||||||
|
}));
|
||||||
|
setGeneratedPublicKey(data.publicKey ?? '');
|
||||||
|
setGeneratedServerId(serverId);
|
||||||
|
setIsGeneratedKey(true);
|
||||||
|
setShowPublicKeyModal(true);
|
||||||
|
setSshKeyError('');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error ?? 'Failed to generate key pair');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating key pair:', error);
|
||||||
|
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingKey(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSSHKeyChange = (value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, ssh_key: value }));
|
||||||
|
if (errors.ssh_key) {
|
||||||
|
setErrors(prev => ({ ...prev, ssh_key: undefined }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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-gray-700 dark:text-gray-300 mb-1">
|
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
Server Name *
|
Server Name *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -83,16 +197,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
id="name"
|
id="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange('name')}
|
onChange={handleChange('name')}
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
errors.name ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
|
errors.name ? 'border-destructive' : 'border-border'
|
||||||
}`}
|
}`}
|
||||||
placeholder="e.g., Production Server"
|
placeholder="e.g., Production Server"
|
||||||
/>
|
/>
|
||||||
{errors.name && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.name}</p>}
|
{errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="ip" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
IP Address *
|
IP Address *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -100,16 +214,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
id="ip"
|
id="ip"
|
||||||
value={formData.ip}
|
value={formData.ip}
|
||||||
onChange={handleChange('ip')}
|
onChange={handleChange('ip')}
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
errors.ip ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
|
errors.ip ? 'border-destructive' : 'border-border'
|
||||||
}`}
|
}`}
|
||||||
placeholder="e.g., 192.168.1.100"
|
placeholder="e.g., 192.168.1.100"
|
||||||
/>
|
/>
|
||||||
{errors.ip && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.ip}</p>}
|
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="user" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
Username *
|
Username *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -117,50 +231,192 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
id="user"
|
id="user"
|
||||||
value={formData.user}
|
value={formData.user}
|
||||||
onChange={handleChange('user')}
|
onChange={handleChange('user')}
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
errors.user ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
|
errors.user ? 'border-destructive' : 'border-border'
|
||||||
}`}
|
}`}
|
||||||
placeholder="e.g., root"
|
placeholder="e.g., root"
|
||||||
/>
|
/>
|
||||||
{errors.user && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.user}</p>}
|
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
SSH Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="ssh_port"
|
||||||
|
value={formData.ssh_port ?? 22}
|
||||||
|
onChange={handleChange('ssh_port')}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
|
errors.ssh_port ? 'border-destructive' : 'border-border'
|
||||||
|
}`}
|
||||||
|
placeholder="22"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
/>
|
||||||
|
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
Authentication Type *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="auth_type"
|
||||||
|
value={formData.auth_type ?? 'password'}
|
||||||
|
onChange={handleChange('auth_type')}
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||||
|
>
|
||||||
|
<option value="password">Password Only</option>
|
||||||
|
<option value="key">SSH Key Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{colorCodingEnabled && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
Server Color
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="color"
|
||||||
|
value={formData.color ?? '#3b82f6'}
|
||||||
|
onChange={handleChange('color')}
|
||||||
|
className="w-20 h-10 rounded cursor-pointer border border-border"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Choose a color to identify this server
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Authentication */}
|
||||||
|
{formData.auth_type === 'password' && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
Password *
|
Password *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
value={formData.password}
|
value={formData.password ?? ''}
|
||||||
onChange={handleChange('password')}
|
onChange={handleChange('password')}
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
errors.password ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
|
errors.password ? 'border-destructive' : 'border-border'
|
||||||
}`}
|
}`}
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
/>
|
/>
|
||||||
{errors.password && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.password}</p>}
|
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SSH Key Authentication */}
|
||||||
|
{formData.auth_type === 'key' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium text-muted-foreground">
|
||||||
|
SSH Private Key *
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerateKeyPair}
|
||||||
|
disabled={isGeneratingKey}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Key className="h-4 w-4" />
|
||||||
|
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4">
|
{/* Show manual key input only if no key has been generated */}
|
||||||
|
{!formData.key_generated && (
|
||||||
|
<>
|
||||||
|
<SSHKeyInput
|
||||||
|
value={formData.ssh_key ?? ''}
|
||||||
|
onChange={handleSSHKeyChange}
|
||||||
|
onError={setSshKeyError}
|
||||||
|
/>
|
||||||
|
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
||||||
|
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show generated key status */}
|
||||||
|
{formData.key_generated && (
|
||||||
|
<div className="p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
SSH key pair generated successfully
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||||
|
The private key has been generated and will be saved with the server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
SSH Key Passphrase (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="ssh_key_passphrase"
|
||||||
|
value={formData.ssh_key_passphrase ?? ''}
|
||||||
|
onChange={handleChange('ssh_key_passphrase')}
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||||
|
placeholder="Enter passphrase for encrypted key"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Only required if your SSH key is encrypted with a passphrase
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
||||||
{isEditing && onCancel && (
|
{isEditing && onCancel && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400"
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto order-2 sm:order-1"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 dark:bg-blue-700 border border-transparent rounded-md hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400"
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto order-1 sm:order-2"
|
||||||
>
|
>
|
||||||
{isEditing ? 'Update Server' : 'Add Server'}
|
{isEditing ? 'Update Server' : 'Add Server'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Public Key Modal */}
|
||||||
|
<PublicKeyModal
|
||||||
|
isOpen={showPublicKeyModal}
|
||||||
|
onClose={() => setShowPublicKeyModal(false)}
|
||||||
|
publicKey={generatedPublicKey}
|
||||||
|
serverName={formData.name || 'New Server'}
|
||||||
|
serverIp={formData.ip}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Server, CreateServerData } from '../../types/server';
|
import type { Server, CreateServerData } from '../../types/server';
|
||||||
import { ServerForm } from './ServerForm';
|
import { ServerForm } from './ServerForm';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
|
import { PublicKeyModal } from './PublicKeyModal';
|
||||||
|
import { Key } from 'lucide-react';
|
||||||
|
|
||||||
interface ServerListProps {
|
interface ServerListProps {
|
||||||
servers: Server[];
|
servers: Server[];
|
||||||
@@ -14,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);
|
||||||
@@ -30,10 +48,47 @@ 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) => {
|
||||||
@@ -71,12 +126,12 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
|
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="mx-auto h-12 w-12 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No servers configured</h3>
|
<h3 className="mt-2 text-sm font-medium text-foreground">No servers configured</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500">Get started by adding a new server configuration above.</p>
|
<p className="mt-1 text-sm text-muted-foreground">Get started by adding a new server configuration above.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,16 +139,25 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
<div key={server.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
<div
|
||||||
|
key={server.id}
|
||||||
|
className="bg-card border border-border rounded-lg p-4 shadow-sm"
|
||||||
|
style={{ borderLeft: `4px solid ${server.color ?? 'transparent'}` }}
|
||||||
|
>
|
||||||
{editingId === server.id ? (
|
{editingId === server.id ? (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-medium text-gray-900 mb-4">Edit Server</h4>
|
<h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
|
||||||
<ServerForm
|
<ServerForm
|
||||||
initialData={{
|
initialData={{
|
||||||
name: server.name,
|
name: server.name,
|
||||||
ip: server.ip,
|
ip: server.ip,
|
||||||
user: server.user,
|
user: server.user,
|
||||||
password: server.password,
|
password: server.password,
|
||||||
|
auth_type: server.auth_type,
|
||||||
|
ssh_key: server.ssh_key,
|
||||||
|
ssh_key_passphrase: server.ssh_key_passphrase,
|
||||||
|
ssh_port: server.ssh_port,
|
||||||
|
color: server.color,
|
||||||
}}
|
}}
|
||||||
onSubmit={handleUpdate}
|
onSubmit={handleUpdate}
|
||||||
isEditing={true}
|
isEditing={true}
|
||||||
@@ -101,35 +165,35 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between space-y-4 sm:space-y-0">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-start sm:items-center space-x-3">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 sm:w-6 sm:h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-medium text-gray-900 truncate">{server.name}</h3>
|
<h3 className="text-base sm:text-lg font-medium text-foreground truncate">{server.name}</h3>
|
||||||
<div className="mt-1 flex items-center space-x-4 text-sm text-gray-500">
|
<div className="mt-1 flex flex-col sm:flex-row sm:items-center space-y-1 sm:space-y-0 sm:space-x-4 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
|
||||||
</svg>
|
</svg>
|
||||||
{server.ip}
|
<span className="truncate">{server.ip}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
{server.user}
|
<span className="truncate">{server.user}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-gray-400">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
Created: {new Date(server.created_at).toLocaleDateString()}
|
Created: {server.created_at ? new Date(server.created_at).toLocaleDateString() : 'Unknown'}
|
||||||
{server.updated_at !== server.created_at && (
|
{server.updated_at && server.updated_at !== server.created_at && (
|
||||||
<span> • Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
|
<span> • Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,51 +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)}
|
||||||
className="inline-flex items-center px-3 py-1.5 border border-green-300 text-xs font-medium rounded text-green-700 bg-white hover:bg-green-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
|
||||||
>
|
>
|
||||||
{testingConnections.has(server.id) ? (
|
{testingConnections.has(server.id) ? (
|
||||||
<>
|
<>
|
||||||
<svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</svg>
|
</svg>
|
||||||
Testing...
|
<span className="hidden sm:inline">Testing...</span>
|
||||||
|
<span className="sm:hidden">Test...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
Test Connection
|
<span className="hidden sm:inline">Test Connection</span>
|
||||||
|
<span className="sm:hidden">Test</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<div className="flex space-x-2">
|
||||||
|
{/* View Public Key button - only show for generated keys */}
|
||||||
|
{server.key_generated === true && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleViewPublicKey(server)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 sm:flex-none border-blue-500/20 text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
|
||||||
|
>
|
||||||
|
<Key className="w-4 h-4 mr-1" />
|
||||||
|
<span className="hidden sm:inline">View Public Key</span>
|
||||||
|
<span className="sm:hidden">Key</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
onClick={() => handleEdit(server)}
|
onClick={() => handleEdit(server)}
|
||||||
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 sm:flex-none"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
</svg>
|
</svg>
|
||||||
Edit
|
<span className="hidden sm:inline">Edit</span>
|
||||||
</button>
|
<span className="sm:hidden">✏️</span>
|
||||||
<button
|
</Button>
|
||||||
|
<Button
|
||||||
onClick={() => handleDelete(server.id)}
|
onClick={() => handleDelete(server.id)}
|
||||||
className="inline-flex items-center px-3 py-1.5 border border-red-300 text-xs font-medium rounded text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 sm:flex-none border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
</svg>
|
</svg>
|
||||||
Delete
|
<span className="hidden sm:inline">Delete</span>
|
||||||
</button>
|
<span className="sm:hidden">🗑️</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { SettingsModal } from './SettingsModal';
|
import { GeneralSettingsModal } from './GeneralSettingsModal';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Settings } from 'lucide-react';
|
||||||
|
|
||||||
export function SettingsButton() {
|
export function SettingsButton() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -9,40 +11,22 @@ export function SettingsButton() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-300 font-medium">
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
Add and manage PVE Servers
|
Application Settings:
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-200"
|
variant="outline"
|
||||||
title="Add PVE Server"
|
size="default"
|
||||||
|
className="inline-flex items-center"
|
||||||
|
title="Open Settings"
|
||||||
>
|
>
|
||||||
<svg
|
<Settings className="w-5 h-5 mr-2" />
|
||||||
className="w-5 h-5 mr-2"
|
Settings
|
||||||
fill="none"
|
</Button>
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Manage PVE Servers
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
<GeneralSettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useState, useEffect } from 'react';
|
|||||||
import type { Server, CreateServerData } from '../../types/server';
|
import type { Server, CreateServerData } from '../../types/server';
|
||||||
import { ServerForm } from './ServerForm';
|
import { ServerForm } from './ServerForm';
|
||||||
import { ServerList } from './ServerList';
|
import { ServerList } from './ServerList';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -14,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) {
|
||||||
@@ -31,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 {
|
||||||
@@ -98,76 +103,55 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 dark:bg-black dark:bg-opacity-70 flex items-center justify-center z-50">
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Settings</h2>
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||||
|
<ContextualHelpIcon section="server-settings" tooltip="Help with Server Settings" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="flex space-x-8 px-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('servers')}
|
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
activeTab === 'servers'
|
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
||||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Server Settings
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('general')}
|
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
activeTab === 'general'
|
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
||||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
General
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
<div className="mb-4 p-3 sm:p-4 bg-destructive/10 border border-destructive rounded-md">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<svg className="h-5 w-5 text-red-400 dark:text-red-300" viewBox="0 0 20 20" fill="currentColor">
|
<svg className="h-4 w-4 sm:h-5 sm:w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3">
|
<div className="ml-2 sm:ml-3 min-w-0 flex-1">
|
||||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
<h3 className="text-xs sm:text-sm font-medium text-red-800">Error</h3>
|
||||||
<div className="mt-2 text-sm text-red-700">{error}</div>
|
<div className="mt-1 sm:mt-2 text-xs sm:text-sm text-red-700 break-words">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'servers' && (
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Server Configurations</h3>
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
|
||||||
<ServerForm onSubmit={handleCreateServer} />
|
<ServerForm onSubmit={handleCreateServer} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Saved Servers</h3>
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Saved Servers</h3>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
<p className="mt-2 text-gray-600">Loading servers...</p>
|
<p className="mt-2 text-gray-600">Loading servers...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,14 +164,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'general' && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">General Settings</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-300">General settings will be available in a future update.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
@@ -9,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,46 +21,133 @@ 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: 'Courier New, monospace',
|
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: 'block',
|
cursorStyle: 'block',
|
||||||
scrollback: 1000,
|
scrollback: 1000,
|
||||||
@@ -68,6 +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
|
||||||
@@ -76,40 +172,70 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
terminal.loadAddon(webLinksAddon);
|
terminal.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
|
// Enable better ANSI handling
|
||||||
|
terminal.options.allowProposedApi = true;
|
||||||
|
|
||||||
// Open terminal
|
// Open terminal
|
||||||
terminal.open(terminalRef.current);
|
terminal.open(terminalElement);
|
||||||
|
|
||||||
|
// Ensure proper terminal rendering
|
||||||
|
setTimeout(() => {
|
||||||
|
terminal.refresh(0, terminal.rows - 1);
|
||||||
|
// Ensure cursor is properly positioned
|
||||||
|
terminal.focus();
|
||||||
|
|
||||||
|
// Force focus on the terminal element
|
||||||
|
terminalElement.focus();
|
||||||
|
terminalElement.click();
|
||||||
|
|
||||||
|
// Add click handler to ensure terminal stays focused
|
||||||
|
const focusHandler = () => {
|
||||||
|
terminal.focus();
|
||||||
|
terminalElement.focus();
|
||||||
|
};
|
||||||
|
terminalElement.addEventListener('click', focusHandler);
|
||||||
|
|
||||||
|
// Store the handler for cleanup
|
||||||
|
(terminalElement as any).focusHandler = focusHandler;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
// Fit after a small delay to ensure proper sizing
|
// Fit after a small delay to ensure proper sizing
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
|
// Force fit multiple times for mobile to ensure proper sizing
|
||||||
|
if (isMobile) {
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
}, 200);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Store references
|
// Add resize listener for mobile responsiveness
|
||||||
xtermRef.current = terminal;
|
|
||||||
fitAddonRef.current = fitAddon;
|
|
||||||
|
|
||||||
// Handle terminal input
|
|
||||||
terminal.onData((data) => {
|
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
||||||
wsRef.current.send(JSON.stringify({
|
|
||||||
action: 'input',
|
|
||||||
executionId,
|
|
||||||
input: data
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle terminal resize
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (fitAddonRef.current) {
|
if (fitAddonRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
fitAddonRef.current.fit();
|
fitAddonRef.current.fit();
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Store the handler for cleanup
|
||||||
|
(terminalElement as any).resizeHandler = handleResize;
|
||||||
|
|
||||||
|
// Store references
|
||||||
|
xtermRef.current = terminal;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
|
// Mark terminal as ready
|
||||||
|
setIsTerminalReady(true);
|
||||||
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -121,13 +247,49 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
if (terminalElement && (terminalElement as any).resizeHandler) {
|
||||||
|
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
|
||||||
|
}
|
||||||
|
if (terminalElement && (terminalElement as any).focusHandler) {
|
||||||
|
terminalElement.removeEventListener('click', (terminalElement as any).focusHandler as (this: HTMLDivElement, ev: PointerEvent) => any);
|
||||||
|
}
|
||||||
if (xtermRef.current) {
|
if (xtermRef.current) {
|
||||||
xtermRef.current.dispose();
|
xtermRef.current.dispose();
|
||||||
xtermRef.current = null;
|
xtermRef.current = null;
|
||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
|
setIsTerminalReady(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [executionId, isClient]);
|
}, [isClient, isMobile]);
|
||||||
|
|
||||||
|
// Handle terminal input with current executionId
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTerminalReady || !xtermRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminal = xtermRef.current;
|
||||||
|
|
||||||
|
const handleData = (data: string) => {
|
||||||
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
const message = {
|
||||||
|
action: 'input',
|
||||||
|
executionId,
|
||||||
|
input: data
|
||||||
|
};
|
||||||
|
wsRef.current.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the handler reference
|
||||||
|
inputHandlerRef.current = handleData;
|
||||||
|
terminal.onData(handleData);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Clear the handler reference
|
||||||
|
inputHandlerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [executionId, isTerminalReady]); // Depend on terminal ready state
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prevent multiple connections in React Strict Mode
|
// Prevent multiple connections in React Strict Mode
|
||||||
@@ -142,6 +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
|
||||||
@@ -157,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
|
||||||
|
if (isInitialConnection && !isRunning) {
|
||||||
|
// Generate a new execution ID for the initial run
|
||||||
|
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
setExecutionId(newExecutionId);
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
action: 'start',
|
action: 'start',
|
||||||
scriptPath,
|
scriptPath,
|
||||||
executionId,
|
executionId: newExecutionId,
|
||||||
mode,
|
mode,
|
||||||
server,
|
server,
|
||||||
isUpdate,
|
isUpdate,
|
||||||
|
isShell,
|
||||||
containerId
|
containerId
|
||||||
};
|
};
|
||||||
ws.send(JSON.stringify(message));
|
ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -204,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}🚀 ${message.data}`);
|
|
||||||
setIsRunning(true);
|
|
||||||
break;
|
|
||||||
case 'output':
|
|
||||||
// Write directly to terminal - xterm.js handles ANSI codes natively
|
|
||||||
xtermRef.current.write(message.data);
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
// Check if this looks like ANSI terminal output (contains escape codes)
|
|
||||||
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
|
|
||||||
// This is likely terminal output sent to stderr, treat it as normal output
|
|
||||||
xtermRef.current.write(message.data);
|
|
||||||
} else if (message.data.includes('TERM environment variable not set')) {
|
|
||||||
// This is a common warning, treat as normal output
|
|
||||||
xtermRef.current.write(message.data);
|
|
||||||
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
|
|
||||||
// This is a script error, show it with error prefix
|
|
||||||
xtermRef.current.writeln(`${prefix}❌ ${message.data}`);
|
|
||||||
} else {
|
|
||||||
// This is a real error, show it with error prefix
|
|
||||||
xtermRef.current.writeln(`${prefix}❌ ${message.data}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'end':
|
|
||||||
xtermRef.current.writeln(`${prefix}✅ ${message.data}`);
|
|
||||||
setIsRunning(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startScript = () => {
|
const startScript = () => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||||
|
// Generate a new execution ID for each script run
|
||||||
|
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
setExecutionId(newExecutionId);
|
||||||
|
|
||||||
|
setIsStopped(false);
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
action: 'start',
|
action: 'start',
|
||||||
scriptPath,
|
scriptPath,
|
||||||
executionId,
|
executionId: newExecutionId,
|
||||||
mode,
|
mode,
|
||||||
server,
|
server,
|
||||||
isUpdate,
|
isUpdate,
|
||||||
|
isShell,
|
||||||
containerId
|
containerId
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -260,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
|
||||||
@@ -273,47 +413,71 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendInput = (input: string) => {
|
||||||
|
setLastInputSent(input);
|
||||||
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
const message = {
|
||||||
|
action: 'input',
|
||||||
|
executionId,
|
||||||
|
input: input
|
||||||
|
};
|
||||||
|
wsRef.current.send(JSON.stringify(message));
|
||||||
|
// Clear the feedback after 2 seconds
|
||||||
|
setTimeout(() => setLastInputSent(null), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileInput = (input: string) => {
|
||||||
|
sendInput(input);
|
||||||
|
setMobileInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleEnterKey = () => {
|
||||||
|
sendInput('\r');
|
||||||
|
};
|
||||||
|
|
||||||
// Don't render on server side
|
// Don't render on server side
|
||||||
if (!isClient) {
|
if (!isClient) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
||||||
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700">
|
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex space-x-1">
|
<div className="flex space-x-1">
|
||||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||||
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-gray-300 font-mono text-sm ml-2">
|
<span className="text-foreground font-mono text-sm ml-2">
|
||||||
{scriptName}
|
{scriptName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-96 w-full flex items-center justify-center">
|
<div className="h-96 w-full flex items-center justify-center">
|
||||||
<div className="text-gray-400">Loading terminal...</div>
|
<div className="text-muted-foreground">Loading terminal...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
||||||
{/* Terminal Header */}
|
{/* Terminal Header */}
|
||||||
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700">
|
<div className="bg-muted px-2 sm:px-4 py-2 flex items-center justify-between border-b border-border">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||||
<div className="flex space-x-1">
|
<div className="flex space-x-1 flex-shrink-0">
|
||||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-red-500 rounded-full"></div>
|
||||||
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-yellow-500 rounded-full"></div>
|
||||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-green-500 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-gray-300 font-mono text-sm ml-2">
|
<span className="text-foreground font-mono text-xs sm:text-sm ml-1 sm:ml-2 truncate">
|
||||||
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
|
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-1 sm:space-x-2 flex-shrink-0">
|
||||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||||
<span className="text-gray-400 text-xs">
|
<span className="text-muted-foreground text-xs hidden sm:inline">
|
||||||
{isConnected ? 'Connected' : 'Disconnected'}
|
{isConnected ? 'Connected' : 'Disconnected'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,51 +486,199 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
{/* Terminal Output */}
|
{/* Terminal Output */}
|
||||||
<div
|
<div
|
||||||
ref={terminalRef}
|
ref={terminalRef}
|
||||||
className="h-96 w-full"
|
className={`h-[16rem] sm:h-[24rem] lg:h-[32rem] w-full max-w-4xl mx-auto ${isMobile ? 'mobile-terminal' : ''}`}
|
||||||
style={{ minHeight: '384px' }}
|
style={{
|
||||||
|
minHeight: '256px'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Terminal Controls */}
|
{/* Mobile Input Controls - Only show on mobile */}
|
||||||
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-t border-gray-700">
|
<div className="block sm:hidden bg-muted/50 px-2 py-3 border-t border-border">
|
||||||
<div className="flex space-x-2">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={startScript}
|
<span className="text-sm font-medium text-foreground">Mobile Input</span>
|
||||||
disabled={!isConnected || isRunning}
|
{lastInputSent && (
|
||||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
<span className="text-xs text-green-500 bg-green-500/10 px-2 py-1 rounded">
|
||||||
isConnected && !isRunning
|
Sent: {lastInputSent === '\r' ? 'Enter' :
|
||||||
? 'bg-green-600 text-white hover:bg-green-700'
|
lastInputSent === ' ' ? 'Space' :
|
||||||
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
lastInputSent === '\b' ? 'Backspace' :
|
||||||
}`}
|
lastInputSent === '\x1b[A' ? 'Up' :
|
||||||
|
lastInputSent === '\x1b[B' ? 'Down' :
|
||||||
|
lastInputSent === '\x1b[C' ? 'Right' :
|
||||||
|
lastInputSent === '\x1b[D' ? 'Left' :
|
||||||
|
lastInputSent}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowMobileInput(!showMobileInput)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
>
|
>
|
||||||
▶️ Start
|
<Keyboard className="h-4 w-4 mr-1" />
|
||||||
</button>
|
{showMobileInput ? 'Hide' : 'Show'} Input
|
||||||
|
</Button>
|
||||||
<button
|
|
||||||
onClick={stopScript}
|
|
||||||
disabled={!isRunning}
|
|
||||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
|
||||||
isRunning
|
|
||||||
? 'bg-red-600 text-white hover:bg-red-700'
|
|
||||||
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
⏹️ Stop
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={clearOutput}
|
|
||||||
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
🗑️ Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{showMobileInput && (
|
||||||
onClick={onClose}
|
<div className="space-y-3">
|
||||||
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
{/* Navigation Buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => sendInput('\x1b[A')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm flex items-center justify-center gap-2"
|
||||||
|
disabled={!isConnected}
|
||||||
>
|
>
|
||||||
✕ Close
|
<ChevronUp className="h-4 w-4" />
|
||||||
</button>
|
Up
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => sendInput('\x1b[B')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm flex items-center justify-center gap-2"
|
||||||
|
disabled={!isConnected}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
Down
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left/Right Navigation Buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => sendInput('\x1b[D')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm flex items-center justify-center gap-2"
|
||||||
|
disabled={!isConnected}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Left
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => sendInput('\x1b[C')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm flex items-center justify-center gap-2"
|
||||||
|
disabled={!isConnected}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
Right
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleEnterKey}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm"
|
||||||
|
disabled={!isConnected}
|
||||||
|
>
|
||||||
|
Enter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => sendInput(' ')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm"
|
||||||
|
disabled={!isConnected}
|
||||||
|
>
|
||||||
|
Space
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => sendInput('\b')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm"
|
||||||
|
disabled={!isConnected}
|
||||||
|
>
|
||||||
|
⌫ Backspace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Input */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mobileInput}
|
||||||
|
onChange={(e) => setMobileInput(e.target.value)}
|
||||||
|
placeholder="Type command..."
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleMobileInput(mobileInput);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isConnected}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleMobileInput(mobileInput)}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={!isConnected || !mobileInput.trim()}
|
||||||
|
className="px-3"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Controls */}
|
||||||
|
<div className="bg-muted px-2 sm:px-4 py-2 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 border-t border-border">
|
||||||
|
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={startScript}
|
||||||
|
disabled={!isConnected || (isRunning && !isStopped)}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className={`text-xs sm:text-sm ${isConnected && (!isRunning || isStopped) ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
|
<span className="hidden sm:inline">Start</span>
|
||||||
|
<span className="sm:hidden">▶</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={stopScript}
|
||||||
|
disabled={!isRunning}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className={`text-xs sm:text-sm ${isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
|
<span className="hidden sm:inline">Stop</span>
|
||||||
|
<span className="sm:hidden">⏹</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={clearOutput}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs sm:text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
|
<span className="hidden sm:inline">Clear</span>
|
||||||
|
<span className="sm:hidden">🗑</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs sm:text-sm bg-gray-600 text-white hover:bg-gray-700 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
interface TextViewerProps {
|
interface TextViewerProps {
|
||||||
scriptName: string;
|
scriptName: string;
|
||||||
@@ -99,44 +100,38 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
>
|
>
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col">
|
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<h2 className="text-2xl font-bold text-gray-800">
|
<h2 className="text-2xl font-bold text-foreground">
|
||||||
Script Viewer: {scriptName}
|
Script Viewer: {scriptName}
|
||||||
</h2>
|
</h2>
|
||||||
{scriptContent.ctScript && scriptContent.installScript && (
|
{scriptContent.ctScript && scriptContent.installScript && (
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button
|
<Button
|
||||||
|
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
|
||||||
onClick={() => setActiveTab('ct')}
|
onClick={() => setActiveTab('ct')}
|
||||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
className="px-3 py-1 text-sm"
|
||||||
activeTab === 'ct'
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
CT Script
|
CT Script
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant={activeTab === 'install' ? 'outline' : 'ghost'}
|
||||||
onClick={() => setActiveTab('install')}
|
onClick={() => setActiveTab('install')}
|
||||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
className="px-3 py-1 text-sm"
|
||||||
activeTab === 'install'
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Install Script
|
Install Script
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -148,11 +143,11 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
|||||||
<div className="flex-1 overflow-hidden flex flex-col">
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-lg text-gray-600">Loading script content...</div>
|
<div className="text-lg text-muted-foreground">Loading script content...</div>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-lg text-red-600">Error: {error}</div>
|
<div className="text-lg text-destructive">Error: {error}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
|
|||||||
306
src/app/_components/VersionDisplay.tsx
Normal file
306
src/app/_components/VersionDisplay.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||||
|
|
||||||
|
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface VersionDisplayProps {
|
||||||
|
onOpenReleaseNotes?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading overlay component with log streaming
|
||||||
|
function LoadingOverlay({
|
||||||
|
isNetworkError = false,
|
||||||
|
logs = []
|
||||||
|
}: {
|
||||||
|
isNetworkError?: boolean;
|
||||||
|
logs?: string[];
|
||||||
|
}) {
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new logs arrive
|
||||||
|
useEffect(() => {
|
||||||
|
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-card rounded-lg p-8 shadow-2xl border border-border max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||||
|
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-card-foreground mb-2">
|
||||||
|
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isNetworkError
|
||||||
|
? 'The server is restarting after the update...'
|
||||||
|
: 'Please stand by while we update your application...'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{isNetworkError
|
||||||
|
? 'This may take a few moments. The page will reload automatically.'
|
||||||
|
: 'The server will restart automatically when complete.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log output */}
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<div className="w-full mt-4 bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-60 overflow-y-auto terminal-output">
|
||||||
|
{logs.map((log, index) => (
|
||||||
|
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
|
||||||
|
{log}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={logsEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {
|
||||||
|
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
const [isNetworkError, setIsNetworkError] = useState(false);
|
||||||
|
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
|
||||||
|
const [shouldSubscribe, setShouldSubscribe] = useState(false);
|
||||||
|
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
||||||
|
const lastLogTimeRef = useRef<number>(Date.now());
|
||||||
|
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const executeUpdate = api.version.executeUpdate.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setUpdateResult({ success: result.success, message: result.message });
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Start subscribing to update logs
|
||||||
|
setShouldSubscribe(true);
|
||||||
|
setUpdateLogs(['Update started...']);
|
||||||
|
} else {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setUpdateResult({ success: false, message: error.message });
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll for update logs
|
||||||
|
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
|
||||||
|
enabled: shouldSubscribe,
|
||||||
|
refetchInterval: 1000, // Poll every second
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update logs when data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateLogsData?.success && updateLogsData.logs) {
|
||||||
|
lastLogTimeRef.current = Date.now();
|
||||||
|
setUpdateLogs(updateLogsData.logs);
|
||||||
|
|
||||||
|
if (updateLogsData.isComplete) {
|
||||||
|
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
||||||
|
setIsNetworkError(true);
|
||||||
|
// Start reconnection attempts when we know update is complete
|
||||||
|
startReconnectAttempts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [updateLogsData]);
|
||||||
|
|
||||||
|
// Monitor for server connection loss and auto-reload (fallback only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldSubscribe) return;
|
||||||
|
|
||||||
|
// Only use this as a fallback - the main trigger should be completion detection
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
|
||||||
|
|
||||||
|
// Only start reconnection if we've been updating for at least 3 minutes
|
||||||
|
// and no logs for 60 seconds (very conservative fallback)
|
||||||
|
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
||||||
|
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
||||||
|
|
||||||
|
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
|
||||||
|
setIsNetworkError(true);
|
||||||
|
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
||||||
|
|
||||||
|
// Start trying to reconnect
|
||||||
|
startReconnectAttempts();
|
||||||
|
}
|
||||||
|
}, 10000); // Check every 10 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(checkInterval);
|
||||||
|
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
|
||||||
|
|
||||||
|
// Attempt to reconnect and reload page when server is back
|
||||||
|
const startReconnectAttempts = () => {
|
||||||
|
if (reconnectIntervalRef.current) return;
|
||||||
|
|
||||||
|
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
|
||||||
|
|
||||||
|
reconnectIntervalRef.current = setInterval(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
// Try to fetch the root path to check if server is back
|
||||||
|
const response = await fetch('/', { method: 'HEAD' });
|
||||||
|
if (response.ok || response.status === 200) {
|
||||||
|
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
|
||||||
|
|
||||||
|
// Clear interval and reload
|
||||||
|
if (reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server still down, keep trying
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup reconnect interval on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (reconnectIntervalRef.current) {
|
||||||
|
clearInterval(reconnectIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
setUpdateResult(null);
|
||||||
|
setIsNetworkError(false);
|
||||||
|
setUpdateLogs([]);
|
||||||
|
setShouldSubscribe(false);
|
||||||
|
setUpdateStartTime(Date.now());
|
||||||
|
lastLogTimeRef.current = Date.now();
|
||||||
|
executeUpdate.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="animate-pulse">
|
||||||
|
Loading...
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !versionStatus?.success) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="destructive">
|
||||||
|
v{versionStatus?.currentVersion ?? 'Unknown'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
(Unable to check for updates)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentVersion, isUpToDate, updateAvailable, releaseInfo } = versionStatus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Loading overlay */}
|
||||||
|
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={isUpToDate ? "default" : "secondary"}
|
||||||
|
className={`text-xs ${onOpenReleaseNotes ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
|
||||||
|
onClick={onOpenReleaseNotes}
|
||||||
|
>
|
||||||
|
v{currentVersion}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{updateAvailable && releaseInfo && (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleUpdate}
|
||||||
|
disabled={isUpdating}
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="text-xs h-6 px-2"
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
<span className="hidden sm:inline">Updating...</span>
|
||||||
|
<span className="sm:hidden">...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-3 w-3 mr-1" />
|
||||||
|
<span className="hidden sm:inline">Update Now</span>
|
||||||
|
<span className="sm:hidden">Update</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ContextualHelpIcon section="update-system" tooltip="Help with updates" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Release Notes:</span>
|
||||||
|
<a
|
||||||
|
href={releaseInfo.htmlUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="View latest release"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateResult && (
|
||||||
|
<div className={`text-xs px-2 py-1 rounded text-center ${
|
||||||
|
updateResult.success
|
||||||
|
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
|
||||||
|
: 'bg-destructive/20 text-destructive border border-destructive/30'
|
||||||
|
}`}>
|
||||||
|
{updateResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUpToDate && (
|
||||||
|
<span className="text-xs text-chart-2">
|
||||||
|
✓ Up to date
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/app/_components/ui/badge.tsx
Normal file
28
src/app/_components/ui/badge.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: "default" | "secondary" | "destructive" | "outline"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||||
|
const variantClasses = {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
variantClasses[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge }
|
||||||
119
src/app/_components/ui/button.tsx
Normal file
119
src/app/_components/ui/button.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { Slot, Slottable } from "@radix-ui/react-slot";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
expandIcon:
|
||||||
|
"group relative text-primary-foreground bg-primary hover:bg-primary/90",
|
||||||
|
ringHover:
|
||||||
|
"bg-primary text-primary-foreground transition-all duration-300 hover:bg-primary/90 hover:ring-2 hover:ring-primary/90 hover:ring-offset-2",
|
||||||
|
shine:
|
||||||
|
"text-primary-foreground animate-shine bg-gradient-to-r from-primary via-primary/75 to-primary bg-[length:400%_100%] ",
|
||||||
|
gooeyRight:
|
||||||
|
"text-primary-foreground relative bg-primary z-0 overflow-hidden transition-all duration-500 before:absolute before:inset-0 before:-z-10 before:translate-x-[150%] before:translate-y-[150%] before:scale-[2.5] before:rounded-[100%] before:bg-gradient-to-r from-zinc-400 before:transition-transform before:duration-1000 hover:before:translate-x-[0%] hover:before:translate-y-[0%] ",
|
||||||
|
gooeyLeft:
|
||||||
|
"text-primary-foreground relative bg-primary z-0 overflow-hidden transition-all duration-500 after:absolute after:inset-0 after:-z-10 after:translate-x-[-150%] after:translate-y-[150%] after:scale-[2.5] after:rounded-[100%] after:bg-gradient-to-l from-zinc-400 after:transition-transform after:duration-1000 hover:after:translate-x-[0%] hover:after:translate-y-[0%] ",
|
||||||
|
linkHover1:
|
||||||
|
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-left after:scale-x-100 hover:after:origin-bottom-right hover:after:scale-x-0 after:transition-transform after:ease-in-out after:duration-300",
|
||||||
|
linkHover2:
|
||||||
|
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-right after:scale-x-0 hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:ease-in-out after:duration-300",
|
||||||
|
// Dark theme action button variants
|
||||||
|
edit: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
update: "bg-cyan-900/20 hover:bg-cyan-900/30 border border-cyan-700/50 text-cyan-300 hover:text-cyan-200 hover:border-cyan-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
shell: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
openui: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
start: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
stop: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
delete: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
|
||||||
|
save: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
|
||||||
|
cancel: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9 ",
|
||||||
|
null: "py-1 px-3 rouded-xs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type IconProps = {
|
||||||
|
Icon: React.ElementType;
|
||||||
|
iconPlacement: "left" | "right";
|
||||||
|
};
|
||||||
|
|
||||||
|
type IconRefProps = {
|
||||||
|
Icon?: never;
|
||||||
|
iconPlacement?: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ButtonProps = {
|
||||||
|
asChild?: boolean;
|
||||||
|
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
|
||||||
|
|
||||||
|
export type ButtonIconProps = IconProps | IconRefProps;
|
||||||
|
|
||||||
|
const Button = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
ButtonProps & ButtonIconProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
Icon,
|
||||||
|
iconPlacement,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{Icon && iconPlacement === "left" && (
|
||||||
|
<div className="group-hover:translate-x-100 w-0 translate-x-[0%] pr-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:pr-2 group-hover:opacity-100">
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Slottable>{props.children}</Slottable>
|
||||||
|
{Icon && iconPlacement === "right" && (
|
||||||
|
<div className="w-0 translate-x-[100%] pl-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:translate-x-0 group-hover:pl-2 group-hover:opacity-100">
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
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-prisma';
|
||||||
|
import { getSSHService } from '../../../../../server/ssh-service';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = parseInt(idParam);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid server ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const server = await db.getServerById(id);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow viewing public key if it was generated by the system
|
||||||
|
if (!(server as any).key_generated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Public key not available for user-provided keys' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(server as any).ssh_key_path) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH key path not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshService = getSSHService();
|
||||||
|
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
publicKey,
|
||||||
|
serverName: (server as any).name,
|
||||||
|
serverIp: (server as any).ip
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving public key:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../../server/database';
|
import { getDatabase } from '../../../../server/database-prisma';
|
||||||
import type { CreateServerData } from '../../../../types/server';
|
import type { CreateServerData } from '../../../../types/server';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -18,7 +18,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const server = db.getServerById(id);
|
const server = await db.getServerById(id);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -52,20 +52,50 @@ 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
|
||||||
const existingServer = db.getServerById(id);
|
const existingServer = await db.getServerById(id);
|
||||||
if (!existingServer) {
|
if (!existingServer) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Server not found' },
|
{ error: 'Server not found' },
|
||||||
@@ -73,12 +103,24 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.updateServer(id, { name, ip, user, password });
|
await db.updateServer(id, {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: authType,
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22,
|
||||||
|
color,
|
||||||
|
key_generated: key_generated ?? false,
|
||||||
|
ssh_key_path
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Server updated successfully',
|
message: 'Server updated successfully',
|
||||||
changes: result.changes
|
changes: 1
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -116,7 +158,7 @@ export async function DELETE(
|
|||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Check if server exists
|
// Check if server exists
|
||||||
const existingServer = db.getServerById(id);
|
const existingServer = await db.getServerById(id);
|
||||||
if (!existingServer) {
|
if (!existingServer) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Server not found' },
|
{ error: 'Server not found' },
|
||||||
@@ -124,12 +166,15 @@ export async function DELETE(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.deleteServer(id);
|
// Delete all installed scripts associated with this server
|
||||||
|
await db.deleteInstalledScriptsByServer(id);
|
||||||
|
|
||||||
|
await db.deleteServer(id);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Server deleted successfully',
|
message: 'Server deleted successfully',
|
||||||
changes: result.changes
|
changes: 1
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../../../server/database';
|
import { getDatabase } from '../../../../../server/database-prisma';
|
||||||
import { getSSHService } from '../../../../../server/ssh-service';
|
import { getSSHService } from '../../../../../server/ssh-service';
|
||||||
import type { Server } from '../../../../../types/server';
|
import type { Server } from '../../../../../types/server';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const server = db.getServerById(id) as Server;
|
const server = await db.getServerById(id) as Server;
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
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-prisma';
|
||||||
|
|
||||||
|
export async function POST(_request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sshService = getSSHService();
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get the next available server ID for key file naming
|
||||||
|
const serverId = await db.getNextServerId();
|
||||||
|
|
||||||
|
const keyPair = await sshService.generateKeyPair(serverId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
privateKey: keyPair.privateKey,
|
||||||
|
publicKey: keyPair.publicKey,
|
||||||
|
serverId: serverId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating SSH key pair:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to generate SSH key pair'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../server/database';
|
import { getDatabase } from '../../../server/database-prisma';
|
||||||
import type { CreateServerData } from '../../../types/server';
|
import type { CreateServerData } from '../../../types/server';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const servers = db.getAllServers();
|
const servers = await db.getAllServers();
|
||||||
return NextResponse.json(servers);
|
return NextResponse.json(servers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching servers:', error);
|
console.error('Error fetching servers:', error);
|
||||||
@@ -20,23 +20,65 @@ 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 = await db.createServer({
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: authType,
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22,
|
||||||
|
color,
|
||||||
|
key_generated: key_generated ?? false,
|
||||||
|
ssh_key_path
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Server created successfully',
|
message: 'Server created successfully',
|
||||||
id: result.lastInsertRowid
|
id: result.id
|
||||||
},
|
},
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ const handler = (req: NextRequest) =>
|
|||||||
env.NODE_ENV === "development"
|
env.NODE_ENV === "development"
|
||||||
? ({ path, error }) => {
|
? ({ path, error }) => {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
`[ERROR] tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
import { type Metadata } from "next";
|
import { type Metadata, type Viewport } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
|
|
||||||
import { TRPCReactProvider } from "~/trpc/react";
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
import { DarkModeProvider } from "./_components/DarkModeProvider";
|
import { AuthProvider } from "./_components/AuthProvider";
|
||||||
import { DarkModeToggle } from "./_components/DarkModeToggle";
|
import { AuthGuard } from "./_components/AuthGuard";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "PVE Scripts local",
|
title: "PVE Scripts local",
|
||||||
description: "",
|
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
|
||||||
icons: [
|
icons: [
|
||||||
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
||||||
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
||||||
@@ -17,56 +17,43 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
const geist = Geist({
|
const geist = Geist({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-jetbrains-mono",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${geist.variable}`}>
|
<html lang="en" className={`${geist.variable} dark`}>
|
||||||
<head>
|
<head>
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
(function() {
|
// Force dark mode
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('theme');
|
|
||||||
const theme = stored && ['light', 'dark', 'system'].includes(stored) ? stored : 'system';
|
|
||||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
const shouldBeDark = theme === 'dark' || (theme === 'system' && systemDark);
|
|
||||||
|
|
||||||
if (shouldBeDark) {
|
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to system preference if localStorage fails
|
|
||||||
const systemDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (systemDark) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors"
|
className="bg-background text-foreground transition-colors"
|
||||||
suppressHydrationWarning={true}
|
suppressHydrationWarning={true}
|
||||||
>
|
>
|
||||||
<DarkModeProvider>
|
<TRPCReactProvider>
|
||||||
{/* Dark Mode Toggle in top right corner */}
|
<AuthProvider>
|
||||||
<div className="fixed top-4 right-4 z-50">
|
<AuthGuard>
|
||||||
<DarkModeToggle />
|
{children}
|
||||||
</div>
|
</AuthGuard>
|
||||||
|
</AuthProvider>
|
||||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
</TRPCReactProvider>
|
||||||
</DarkModeProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
236
src/app/page.tsx
236
src/app/page.tsx
@@ -1,19 +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 { 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 { Button } from './_components/ui/button';
|
||||||
|
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||||
|
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||||
|
import { Footer } from './_components/Footer';
|
||||||
|
import { Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'scripts' | 'installed'>('scripts');
|
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
|
||||||
|
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 = () => {
|
||||||
@@ -21,54 +142,87 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-100 dark:bg-gray-900">
|
<main className="min-h-screen bg-background">
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-6 sm:mb-8">
|
||||||
<h1 className="text-4xl font-bold text-gray-800 dark:text-gray-100 mb-2">
|
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-2 sm:gap-3">
|
||||||
🚀 PVE Scripts Management
|
|
||||||
|
<span className="break-words">PVE Scripts Management</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
|
||||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex justify-center px-2">
|
||||||
|
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
<ServerSettingsButton />
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
|
||||||
<ResyncButton />
|
<ResyncButton />
|
||||||
</div>
|
<HelpButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
<div className="border-b border-border">
|
||||||
<nav className="-mb-px flex space-x-8">
|
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="null"
|
||||||
onClick={() => setActiveTab('scripts')}
|
onClick={() => setActiveTab('scripts')}
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||||
activeTab === 'scripts'
|
activeTab === 'scripts'
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||||
}`}
|
}`}>
|
||||||
>
|
<Package className="h-4 w-4" />
|
||||||
📦 Available Scripts
|
<span className="hidden sm:inline">Available Scripts</span>
|
||||||
</button>
|
<span className="sm:hidden">Available</span>
|
||||||
<button
|
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||||
|
{scriptCounts.available}
|
||||||
|
</span>
|
||||||
|
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="null"
|
||||||
|
onClick={() => setActiveTab('downloaded')}
|
||||||
|
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||||
|
activeTab === 'downloaded'
|
||||||
|
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||||
|
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||||
|
}`}>
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Downloaded Scripts</span>
|
||||||
|
<span className="sm:hidden">Downloaded</span>
|
||||||
|
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||||
|
{scriptCounts.downloaded}
|
||||||
|
</span>
|
||||||
|
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="null"
|
||||||
onClick={() => setActiveTab('installed')}
|
onClick={() => setActiveTab('installed')}
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||||
activeTab === 'installed'
|
activeTab === 'installed'
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||||
}`}
|
}`}>
|
||||||
>
|
<FolderOpen className="h-4 w-4" />
|
||||||
🗂️ Installed Scripts
|
<span className="hidden sm:inline">Installed Scripts</span>
|
||||||
</button>
|
<span className="sm:hidden">Installed</span>
|
||||||
|
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||||
|
{scriptCounts.installed}
|
||||||
|
</span>
|
||||||
|
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
|
||||||
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,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}
|
||||||
@@ -92,10 +246,24 @@ export default function Home() {
|
|||||||
<ScriptsGrid onInstallScript={handleRunScript} />
|
<ScriptsGrid onInstallScript={handleRunScript} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'downloaded' && (
|
||||||
|
<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
|
||||||
|
}
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { scriptsRouter } from "~/server/api/routers/scripts";
|
import { scriptsRouter } from "~/server/api/routers/scripts";
|
||||||
import { installedScriptsRouter } from "~/server/api/routers/installedScripts";
|
import { installedScriptsRouter } from "~/server/api/routers/installedScripts";
|
||||||
import { serversRouter } from "~/server/api/routers/servers";
|
import { serversRouter } from "~/server/api/routers/servers";
|
||||||
|
import { versionRouter } from "~/server/api/routers/version";
|
||||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +13,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
scripts: scriptsRouter,
|
scripts: scriptsRouter,
|
||||||
installedScripts: installedScriptsRouter,
|
installedScripts: installedScriptsRouter,
|
||||||
servers: serversRouter,
|
servers: serversRouter,
|
||||||
|
version: versionRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,13 @@
|
|||||||
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-prisma";
|
||||||
|
|
||||||
export const serversRouter = createTRPCRouter({
|
export const serversRouter = createTRPCRouter({
|
||||||
getAllServers: publicProcedure
|
getAllServers: publicProcedure
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const servers = db.getAllServers();
|
const servers = await db.getAllServers();
|
||||||
return { success: true, servers };
|
return { success: true, servers };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching servers:', error);
|
console.error('Error fetching servers:', error);
|
||||||
@@ -24,7 +24,7 @@ export const serversRouter = createTRPCRouter({
|
|||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const server = db.getServerById(input.id);
|
const server = await db.getServerById(input.id);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return { success: false, error: 'Server not found', server: null };
|
return { success: false, error: 'Server not found', server: null };
|
||||||
}
|
}
|
||||||
|
|||||||
263
src/server/api/routers/version.ts
Normal file
263
src/server/api/routers/version.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
|
import { readFile, writeFile } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { existsSync, createWriteStream } from "fs";
|
||||||
|
import stripAnsi from "strip-ansi";
|
||||||
|
|
||||||
|
interface GitHubRelease {
|
||||||
|
tag_name: string;
|
||||||
|
name: string;
|
||||||
|
published_at: string;
|
||||||
|
html_url: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to fetch from GitHub API with optional authentication
|
||||||
|
async function fetchGitHubAPI(url: string) {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'ProxmoxVE-Local'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add authentication header if token is available
|
||||||
|
if (env.GITHUB_TOKEN) {
|
||||||
|
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const versionRouter = createTRPCRouter({
|
||||||
|
// Get current local version
|
||||||
|
getCurrentVersion: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
try {
|
||||||
|
const versionPath = join(process.cwd(), 'VERSION');
|
||||||
|
const version = await readFile(versionPath, 'utf-8');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
version: version.trim()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading VERSION file:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to read VERSION file',
|
||||||
|
version: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
getLatestRelease: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const release: GitHubRelease = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
release: {
|
||||||
|
tagName: release.tag_name,
|
||||||
|
name: release.name,
|
||||||
|
publishedAt: release.published_at,
|
||||||
|
htmlUrl: release.html_url
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching latest release:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch latest release',
|
||||||
|
release: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
|
||||||
|
getVersionStatus: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const versionPath = join(process.cwd(), 'VERSION');
|
||||||
|
const currentVersion = (await readFile(versionPath, 'utf-8')).trim();
|
||||||
|
|
||||||
|
|
||||||
|
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const release: GitHubRelease = await response.json();
|
||||||
|
const latestVersion = release.tag_name.replace('v', '');
|
||||||
|
|
||||||
|
|
||||||
|
const isUpToDate = currentVersion === latestVersion;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
isUpToDate,
|
||||||
|
updateAvailable: !isUpToDate,
|
||||||
|
releaseInfo: {
|
||||||
|
tagName: release.tag_name,
|
||||||
|
name: release.name,
|
||||||
|
publishedAt: release.published_at,
|
||||||
|
htmlUrl: release.html_url
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking version status:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to check version status',
|
||||||
|
currentVersion: null,
|
||||||
|
latestVersion: null,
|
||||||
|
isUpToDate: false,
|
||||||
|
updateAvailable: false,
|
||||||
|
releaseInfo: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get all releases for release notes
|
||||||
|
getAllReleases: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases: GitHubRelease[] = await response.json();
|
||||||
|
|
||||||
|
// Sort by published date (newest first)
|
||||||
|
const sortedReleases = releases
|
||||||
|
.filter(release => !release.tag_name.includes('beta') && !release.tag_name.includes('alpha'))
|
||||||
|
.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime());
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
releases: sortedReleases.map(release => ({
|
||||||
|
tagName: release.tag_name,
|
||||||
|
name: release.name,
|
||||||
|
publishedAt: release.published_at,
|
||||||
|
htmlUrl: release.html_url,
|
||||||
|
body: release.body
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching all releases:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch releases',
|
||||||
|
releases: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get update logs from the log file
|
||||||
|
getUpdateLogs: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
try {
|
||||||
|
const logPath = join(process.cwd(), 'update.log');
|
||||||
|
|
||||||
|
if (!existsSync(logPath)) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
logs: [],
|
||||||
|
isComplete: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await readFile(logPath, 'utf-8');
|
||||||
|
const logLines = logs.split('\n')
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map(line => stripAnsi(line)); // Strip ANSI color codes
|
||||||
|
|
||||||
|
// Check if update is complete by looking for completion indicators
|
||||||
|
const isComplete = logLines.some(line =>
|
||||||
|
line.includes('Update complete') ||
|
||||||
|
line.includes('Server restarting') ||
|
||||||
|
line.includes('npm start') ||
|
||||||
|
line.includes('Restarting server') ||
|
||||||
|
line.includes('Server started') ||
|
||||||
|
line.includes('Ready on http') ||
|
||||||
|
line.includes('Application started') ||
|
||||||
|
line.includes('Service enabled and started successfully') ||
|
||||||
|
line.includes('Service is running') ||
|
||||||
|
line.includes('Update completed successfully')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
logs: logLines,
|
||||||
|
isComplete
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading update logs:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to read update logs',
|
||||||
|
logs: [],
|
||||||
|
isComplete: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Execute update script
|
||||||
|
executeUpdate: publicProcedure
|
||||||
|
.mutation(async () => {
|
||||||
|
try {
|
||||||
|
const updateScriptPath = join(process.cwd(), 'update.sh');
|
||||||
|
const logPath = join(process.cwd(), 'update.log');
|
||||||
|
|
||||||
|
// Clear/create the log file
|
||||||
|
await writeFile(logPath, '', 'utf-8');
|
||||||
|
|
||||||
|
// Spawn the update script as a detached process using nohup
|
||||||
|
// This allows it to run independently and kill the parent Node.js process
|
||||||
|
// Redirect output to log file
|
||||||
|
const child = spawn('bash', [updateScriptPath], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
shell: false,
|
||||||
|
detached: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture stdout and stderr to log file
|
||||||
|
const logStream = createWriteStream(logPath, { flags: 'a' });
|
||||||
|
child.stdout?.pipe(logStream);
|
||||||
|
child.stderr?.pipe(logStream);
|
||||||
|
|
||||||
|
// Unref the child process so it doesn't keep the parent alive
|
||||||
|
child.unref();
|
||||||
|
|
||||||
|
// Immediately return success since we can't wait for completion
|
||||||
|
// The script will handle its own logging and restart
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Update started in background. The server will restart automatically when complete.',
|
||||||
|
output: '',
|
||||||
|
error: ''
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing update script:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to execute update script: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
output: '',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -25,8 +25,11 @@ export class ScriptExecutionHandler {
|
|||||||
|
|
||||||
private handleConnection(ws: WebSocket, _request: IncomingMessage) {
|
private handleConnection(ws: WebSocket, _request: IncomingMessage) {
|
||||||
|
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
||||||
const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string };
|
const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string };
|
||||||
void this.handleMessage(ws, message);
|
void this.handleMessage(ws, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -40,20 +43,20 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
|
|
||||||
// Clean up any active executions for this connection
|
// Clean up any active executions for this connection
|
||||||
this.cleanupActiveExecutions(ws);
|
this.cleanupActiveExecutions(ws);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
ws.on('error', (_error) => {
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
this.cleanupActiveExecutions(ws);
|
this.cleanupActiveExecutions(ws);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any }) {
|
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any; input?: string }) {
|
||||||
const { action, scriptPath, executionId, mode, server } = message;
|
const { action, scriptPath, executionId, mode, server, input } = message;
|
||||||
|
|
||||||
|
|
||||||
console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
|
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
@@ -74,6 +77,20 @@ export class ScriptExecutionHandler {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'input':
|
||||||
|
if (executionId && input !== undefined) {
|
||||||
|
|
||||||
|
this.sendInputToExecution(executionId, input);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: 'Missing executionId or input data',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -84,7 +101,6 @@ export class ScriptExecutionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) {
|
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) {
|
||||||
console.log('startScriptExecution called with:', { scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if execution is already running
|
// Check if execution is already running
|
||||||
@@ -100,10 +116,7 @@ export class ScriptExecutionHandler {
|
|||||||
let process: any;
|
let process: any;
|
||||||
|
|
||||||
if (mode === 'ssh' && server) {
|
if (mode === 'ssh' && server) {
|
||||||
// SSH execution
|
|
||||||
console.log('Starting SSH execution:', { scriptPath, server });
|
|
||||||
console.log('SSH execution mode detected, calling SSH service...');
|
|
||||||
console.log('Mode check: mode=', mode, 'server=', !!server);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'start',
|
type: 'start',
|
||||||
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
|
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
|
||||||
@@ -111,13 +124,11 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sshService = getSSHExecutionService();
|
const sshService = getSSHExecutionService();
|
||||||
console.log('SSH service obtained, calling executeScript...');
|
|
||||||
console.log('SSH service object:', typeof sshService, sshService.constructor.name);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sshService.executeScript(server as Server, scriptPath,
|
const result = await sshService.executeScript(server as Server, scriptPath,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
console.log('SSH onData callback:', data.substring(0, 100) + '...');
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'output',
|
type: 'output',
|
||||||
data: data,
|
data: data,
|
||||||
@@ -125,7 +136,7 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
console.log('SSH onError callback:', error);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
data: error,
|
data: error,
|
||||||
@@ -133,7 +144,7 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
(code: number) => {
|
(code: number) => {
|
||||||
console.log('SSH onExit callback, code:', code);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'end',
|
type: 'end',
|
||||||
data: `SSH script execution finished with code: ${code}`,
|
data: `SSH script execution finished with code: ${code}`,
|
||||||
@@ -142,10 +153,10 @@ export class ScriptExecutionHandler {
|
|||||||
this.activeExecutions.delete(executionId);
|
this.activeExecutions.delete(executionId);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
console.log('SSH service executeScript completed, result:', result);
|
|
||||||
process = (result as any).process;
|
process = (result as any).process;
|
||||||
} catch (sshError) {
|
} catch (sshError) {
|
||||||
console.error('SSH service executeScript failed:', sshError);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
|
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
|
||||||
@@ -154,10 +165,7 @@ export class ScriptExecutionHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Local execution
|
|
||||||
console.log('Starting local execution:', { scriptPath });
|
|
||||||
console.log('Local execution mode detected, calling local script manager...');
|
|
||||||
console.log('Mode check: mode=', mode, 'server=', !!server, 'condition result:', mode === 'ssh' && server);
|
|
||||||
|
|
||||||
// Validate script path
|
// Validate script path
|
||||||
const validation = scriptManager.validateScriptPath(scriptPath);
|
const validation = scriptManager.validateScriptPath(scriptPath);
|
||||||
@@ -249,6 +257,59 @@ export class ScriptExecutionHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sendInputToExecution(executionId: string, input: string) {
|
||||||
|
|
||||||
|
const execution = this.activeExecutions.get(executionId);
|
||||||
|
|
||||||
|
|
||||||
|
if (execution?.process) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if it's a pty process (SSH) or regular process
|
||||||
|
if (typeof execution.process.write === 'function' && !execution.process.stdin) {
|
||||||
|
|
||||||
|
|
||||||
|
execution.process.write(input);
|
||||||
|
|
||||||
|
|
||||||
|
// Send confirmation back to client
|
||||||
|
this.sendMessage(execution.ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else if (execution.process.stdin && !execution.process.stdin.destroyed) {
|
||||||
|
|
||||||
|
execution.process.stdin.write(input);
|
||||||
|
|
||||||
|
|
||||||
|
this.sendMessage(execution.ws, {
|
||||||
|
type: 'output',
|
||||||
|
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
|
||||||
|
this.sendMessage(execution.ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: 'Process input not available',
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
this.sendMessage(execution.ws, {
|
||||||
|
type: 'error',
|
||||||
|
data: `Failed to send input: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No active execution found - this case is already handled above
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) {
|
private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify(message));
|
ws.send(JSON.stringify(message));
|
||||||
|
|||||||
287
src/server/database-prisma.js
Normal file
287
src/server/database-prisma.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { prisma } from './db.js';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
class DatabaseServicePrisma {
|
||||||
|
constructor() {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Ensure data/ssh-keys directory exists
|
||||||
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
|
if (!existsSync(sshKeysDir)) {
|
||||||
|
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server CRUD operations
|
||||||
|
async createServer(serverData) {
|
||||||
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||||
|
|
||||||
|
let ssh_key_path = null;
|
||||||
|
|
||||||
|
// If using SSH key authentication, create persistent key file
|
||||||
|
if (auth_type === 'key' && ssh_key) {
|
||||||
|
const serverId = await this.getNextServerId();
|
||||||
|
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.server.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: auth_type ?? 'password',
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22,
|
||||||
|
ssh_key_path,
|
||||||
|
key_generated: Boolean(key_generated),
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllServers() {
|
||||||
|
return await prisma.server.findMany({
|
||||||
|
orderBy: { created_at: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServerById(id) {
|
||||||
|
return await prisma.server.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateServer(id, serverData) {
|
||||||
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||||
|
|
||||||
|
// Get existing server to check for key changes
|
||||||
|
const existingServer = await this.getServerById(id);
|
||||||
|
let ssh_key_path = existingServer?.ssh_key_path;
|
||||||
|
|
||||||
|
// Handle SSH key changes
|
||||||
|
if (auth_type === 'key' && ssh_key) {
|
||||||
|
// Delete old key file if it exists
|
||||||
|
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(existingServer.ssh_key_path);
|
||||||
|
// Also delete public key file if it exists
|
||||||
|
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete old SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new key file
|
||||||
|
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
|
||||||
|
} else if (auth_type !== 'key') {
|
||||||
|
// If switching away from key auth, delete key files
|
||||||
|
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(existingServer.ssh_key_path);
|
||||||
|
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ssh_key_path = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.server.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: auth_type ?? 'password',
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22,
|
||||||
|
ssh_key_path,
|
||||||
|
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteServer(id) {
|
||||||
|
// Get server info before deletion to clean up key files
|
||||||
|
const server = await this.getServerById(id);
|
||||||
|
|
||||||
|
// Delete SSH key files if they exist
|
||||||
|
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(server.ssh_key_path);
|
||||||
|
const pubKeyPath = server.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.server.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installed Scripts CRUD operations
|
||||||
|
async createInstalledScript(scriptData) {
|
||||||
|
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
||||||
|
|
||||||
|
return await prisma.installedScript.create({
|
||||||
|
data: {
|
||||||
|
script_name,
|
||||||
|
script_path,
|
||||||
|
container_id: container_id ?? null,
|
||||||
|
server_id: server_id ?? null,
|
||||||
|
execution_mode,
|
||||||
|
status,
|
||||||
|
output_log: output_log ?? null,
|
||||||
|
web_ui_ip: web_ui_ip ?? null,
|
||||||
|
web_ui_port: web_ui_port ?? null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllInstalledScripts() {
|
||||||
|
return await prisma.installedScript.findMany({
|
||||||
|
include: {
|
||||||
|
server: true
|
||||||
|
},
|
||||||
|
orderBy: { installation_date: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInstalledScriptById(id) {
|
||||||
|
return await prisma.installedScript.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
server: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInstalledScriptsByServer(server_id) {
|
||||||
|
return await prisma.installedScript.findMany({
|
||||||
|
where: { server_id },
|
||||||
|
include: {
|
||||||
|
server: true
|
||||||
|
},
|
||||||
|
orderBy: { installation_date: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInstalledScript(id, updateData) {
|
||||||
|
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
||||||
|
|
||||||
|
const updateFields = {};
|
||||||
|
if (script_name !== undefined) updateFields.script_name = script_name;
|
||||||
|
if (container_id !== undefined) updateFields.container_id = container_id;
|
||||||
|
if (status !== undefined) updateFields.status = status;
|
||||||
|
if (output_log !== undefined) updateFields.output_log = output_log;
|
||||||
|
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
|
||||||
|
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
|
||||||
|
|
||||||
|
if (Object.keys(updateFields).length === 0) {
|
||||||
|
return { changes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.installedScript.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateFields
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInstalledScript(id) {
|
||||||
|
return await prisma.installedScript.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInstalledScriptsByServer(server_id) {
|
||||||
|
return await prisma.installedScript.deleteMany({
|
||||||
|
where: { server_id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNextServerId() {
|
||||||
|
const result = await prisma.server.findFirst({
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return (result?.id ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSSHKeyFile(serverId, sshKey) {
|
||||||
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
|
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||||
|
|
||||||
|
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||||
|
const normalizedKey = sshKey.trimEnd() + '\n';
|
||||||
|
writeFileSync(keyPath, normalizedKey);
|
||||||
|
chmodSync(keyPath, 0o600); // Set proper permissions
|
||||||
|
|
||||||
|
return keyPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LXC Config CRUD operations
|
||||||
|
async createLXCConfig(scriptId, configData) {
|
||||||
|
return await prisma.lXCConfig.create({
|
||||||
|
data: {
|
||||||
|
installed_script_id: scriptId,
|
||||||
|
...configData
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLXCConfig(scriptId, configData) {
|
||||||
|
return await prisma.lXCConfig.upsert({
|
||||||
|
where: { installed_script_id: scriptId },
|
||||||
|
update: configData,
|
||||||
|
create: {
|
||||||
|
installed_script_id: scriptId,
|
||||||
|
...configData
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLXCConfigByScriptId(scriptId) {
|
||||||
|
return await prisma.lXCConfig.findUnique({
|
||||||
|
where: { installed_script_id: scriptId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLXCConfig(scriptId) {
|
||||||
|
return await prisma.lXCConfig.delete({
|
||||||
|
where: { installed_script_id: scriptId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
let dbInstance = null;
|
||||||
|
|
||||||
|
export function getDatabase() {
|
||||||
|
dbInstance ??= new DatabaseServicePrisma();
|
||||||
|
return dbInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DatabaseServicePrisma;
|
||||||
312
src/server/database-prisma.ts
Normal file
312
src/server/database-prisma.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { prisma } from './db';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import type { CreateServerData } from '../types/server';
|
||||||
|
|
||||||
|
class DatabaseServicePrisma {
|
||||||
|
constructor() {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Ensure data/ssh-keys directory exists
|
||||||
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
|
if (!existsSync(sshKeysDir)) {
|
||||||
|
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server CRUD operations
|
||||||
|
async createServer(serverData: CreateServerData) {
|
||||||
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||||
|
|
||||||
|
let ssh_key_path = null;
|
||||||
|
|
||||||
|
// If using SSH key authentication, create persistent key file
|
||||||
|
if (auth_type === 'key' && ssh_key) {
|
||||||
|
const serverId = await this.getNextServerId();
|
||||||
|
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.server.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: auth_type ?? 'password',
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22,
|
||||||
|
ssh_key_path,
|
||||||
|
key_generated: Boolean(key_generated),
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllServers() {
|
||||||
|
return await prisma.server.findMany({
|
||||||
|
orderBy: { created_at: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServerById(id: number) {
|
||||||
|
return await prisma.server.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateServer(id: number, serverData: CreateServerData) {
|
||||||
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||||
|
|
||||||
|
// Get existing server to check for key changes
|
||||||
|
const existingServer = await this.getServerById(id);
|
||||||
|
let ssh_key_path = existingServer?.ssh_key_path;
|
||||||
|
|
||||||
|
// Handle SSH key changes
|
||||||
|
if (auth_type === 'key' && ssh_key) {
|
||||||
|
// Delete old key file if it exists
|
||||||
|
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(existingServer.ssh_key_path);
|
||||||
|
// Also delete public key file if it exists
|
||||||
|
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete old SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new key file
|
||||||
|
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
|
||||||
|
} else if (auth_type !== 'key') {
|
||||||
|
// If switching away from key auth, delete key files
|
||||||
|
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(existingServer.ssh_key_path);
|
||||||
|
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ssh_key_path = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.server.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: auth_type ?? 'password',
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22,
|
||||||
|
ssh_key_path,
|
||||||
|
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteServer(id: number) {
|
||||||
|
// Get server info before deletion to clean up key files
|
||||||
|
const server = await this.getServerById(id);
|
||||||
|
|
||||||
|
// Delete SSH key files if they exist
|
||||||
|
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(server.ssh_key_path);
|
||||||
|
const pubKeyPath = server.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.server.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installed Scripts CRUD operations
|
||||||
|
async createInstalledScript(scriptData: {
|
||||||
|
script_name: string;
|
||||||
|
script_path: string;
|
||||||
|
container_id?: string;
|
||||||
|
server_id?: number;
|
||||||
|
execution_mode: string;
|
||||||
|
status: 'in_progress' | 'success' | 'failed';
|
||||||
|
output_log?: string;
|
||||||
|
web_ui_ip?: string;
|
||||||
|
web_ui_port?: number;
|
||||||
|
}) {
|
||||||
|
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
||||||
|
|
||||||
|
return await prisma.installedScript.create({
|
||||||
|
data: {
|
||||||
|
script_name,
|
||||||
|
script_path,
|
||||||
|
container_id: container_id ?? null,
|
||||||
|
server_id: server_id ?? null,
|
||||||
|
execution_mode,
|
||||||
|
status,
|
||||||
|
output_log: output_log ?? null,
|
||||||
|
web_ui_ip: web_ui_ip ?? null,
|
||||||
|
web_ui_port: web_ui_port ?? null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllInstalledScripts() {
|
||||||
|
return await prisma.installedScript.findMany({
|
||||||
|
include: {
|
||||||
|
server: true
|
||||||
|
},
|
||||||
|
orderBy: { installation_date: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInstalledScriptById(id: number) {
|
||||||
|
return await prisma.installedScript.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
server: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInstalledScriptsByServer(server_id: number) {
|
||||||
|
return await prisma.installedScript.findMany({
|
||||||
|
where: { server_id },
|
||||||
|
include: {
|
||||||
|
server: true
|
||||||
|
},
|
||||||
|
orderBy: { installation_date: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInstalledScript(id: number, updateData: {
|
||||||
|
script_name?: string;
|
||||||
|
container_id?: string;
|
||||||
|
status?: 'in_progress' | 'success' | 'failed';
|
||||||
|
output_log?: string;
|
||||||
|
web_ui_ip?: string;
|
||||||
|
web_ui_port?: number;
|
||||||
|
}) {
|
||||||
|
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
||||||
|
|
||||||
|
const updateFields: {
|
||||||
|
script_name?: string;
|
||||||
|
container_id?: string;
|
||||||
|
status?: 'in_progress' | 'success' | 'failed';
|
||||||
|
output_log?: string;
|
||||||
|
web_ui_ip?: string;
|
||||||
|
web_ui_port?: number;
|
||||||
|
} = {};
|
||||||
|
if (script_name !== undefined) updateFields.script_name = script_name;
|
||||||
|
if (container_id !== undefined) updateFields.container_id = container_id;
|
||||||
|
if (status !== undefined) updateFields.status = status;
|
||||||
|
if (output_log !== undefined) updateFields.output_log = output_log;
|
||||||
|
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
|
||||||
|
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
|
||||||
|
|
||||||
|
if (Object.keys(updateFields).length === 0) {
|
||||||
|
return { changes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.installedScript.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateFields
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInstalledScript(id: number) {
|
||||||
|
return await prisma.installedScript.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInstalledScriptsByServer(server_id: number) {
|
||||||
|
return await prisma.installedScript.deleteMany({
|
||||||
|
where: { server_id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNextServerId() {
|
||||||
|
const result = await prisma.server.findFirst({
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return (result?.id ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSSHKeyFile(serverId: number, sshKey: string) {
|
||||||
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
|
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||||
|
|
||||||
|
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||||
|
const normalizedKey = sshKey.trimEnd() + '\n';
|
||||||
|
writeFileSync(keyPath, normalizedKey);
|
||||||
|
chmodSync(keyPath, 0o600); // Set proper permissions
|
||||||
|
|
||||||
|
return keyPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LXC Config CRUD operations
|
||||||
|
async createLXCConfig(scriptId: number, configData: any) {
|
||||||
|
return await prisma.lXCConfig.create({
|
||||||
|
data: {
|
||||||
|
installed_script_id: scriptId,
|
||||||
|
...configData
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLXCConfig(scriptId: number, configData: any) {
|
||||||
|
return await prisma.lXCConfig.upsert({
|
||||||
|
where: { installed_script_id: scriptId },
|
||||||
|
update: configData,
|
||||||
|
create: {
|
||||||
|
installed_script_id: scriptId,
|
||||||
|
...configData
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLXCConfigByScriptId(scriptId: number) {
|
||||||
|
return await prisma.lXCConfig.findUnique({
|
||||||
|
where: { installed_script_id: scriptId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLXCConfig(scriptId: number) {
|
||||||
|
return await prisma.lXCConfig.delete({
|
||||||
|
where: { installed_script_id: scriptId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
let dbInstance: DatabaseServicePrisma | null = null;
|
||||||
|
|
||||||
|
export function getDatabase() {
|
||||||
|
dbInstance ??= new DatabaseServicePrisma();
|
||||||
|
return dbInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DatabaseServicePrisma;
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import Database from 'better-sqlite3';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
class DatabaseService {
|
|
||||||
constructor() {
|
|
||||||
const dbPath = join(process.cwd(), 'data', 'settings.db');
|
|
||||||
this.db = new Database(dbPath);
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Create servers table if it doesn't exist
|
|
||||||
this.db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS servers (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
ip TEXT NOT NULL,
|
|
||||||
user TEXT NOT NULL,
|
|
||||||
password TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create installed_scripts table if it doesn't exist
|
|
||||||
this.db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS installed_scripts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
script_name TEXT NOT NULL,
|
|
||||||
script_path TEXT NOT NULL,
|
|
||||||
container_id TEXT,
|
|
||||||
server_id INTEGER,
|
|
||||||
execution_mode TEXT NOT NULL CHECK(execution_mode IN ('local', 'ssh')),
|
|
||||||
installation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')),
|
|
||||||
output_log TEXT,
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create trigger to update updated_at on row update
|
|
||||||
this.db.exec(`
|
|
||||||
CREATE TRIGGER IF NOT EXISTS update_servers_timestamp
|
|
||||||
AFTER UPDATE ON servers
|
|
||||||
BEGIN
|
|
||||||
UPDATE servers SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server CRUD operations
|
|
||||||
/**
|
|
||||||
* @param {import('../types/server').CreateServerData} serverData
|
|
||||||
*/
|
|
||||||
createServer(serverData) {
|
|
||||||
const { name, ip, user, password } = serverData;
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
INSERT INTO servers (name, ip, user, password)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
return stmt.run(name, ip, user, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllServers() {
|
|
||||||
const stmt = this.db.prepare('SELECT * FROM servers ORDER BY created_at DESC');
|
|
||||||
return stmt.all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} id
|
|
||||||
*/
|
|
||||||
getServerById(id) {
|
|
||||||
const stmt = this.db.prepare('SELECT * FROM servers WHERE id = ?');
|
|
||||||
return stmt.get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} id
|
|
||||||
* @param {import('../types/server').CreateServerData} serverData
|
|
||||||
*/
|
|
||||||
updateServer(id, serverData) {
|
|
||||||
const { name, ip, user, password } = serverData;
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
UPDATE servers
|
|
||||||
SET name = ?, ip = ?, user = ?, password = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
return stmt.run(name, ip, user, password, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} id
|
|
||||||
*/
|
|
||||||
deleteServer(id) {
|
|
||||||
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
|
|
||||||
return stmt.run(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Installed Scripts CRUD operations
|
|
||||||
/**
|
|
||||||
* @param {Object} scriptData
|
|
||||||
* @param {string} scriptData.script_name
|
|
||||||
* @param {string} scriptData.script_path
|
|
||||||
* @param {string} [scriptData.container_id]
|
|
||||||
* @param {number} [scriptData.server_id]
|
|
||||||
* @param {string} scriptData.execution_mode
|
|
||||||
* @param {string} scriptData.status
|
|
||||||
* @param {string} [scriptData.output_log]
|
|
||||||
*/
|
|
||||||
createInstalledScript(scriptData) {
|
|
||||||
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log } = scriptData;
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllInstalledScripts() {
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT
|
|
||||||
inst.*,
|
|
||||||
s.name as server_name,
|
|
||||||
s.ip as server_ip,
|
|
||||||
s.user as server_user,
|
|
||||||
s.password as server_password
|
|
||||||
FROM installed_scripts inst
|
|
||||||
LEFT JOIN servers s ON inst.server_id = s.id
|
|
||||||
ORDER BY inst.installation_date DESC
|
|
||||||
`);
|
|
||||||
return stmt.all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} id
|
|
||||||
*/
|
|
||||||
getInstalledScriptById(id) {
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT
|
|
||||||
inst.*,
|
|
||||||
s.name as server_name,
|
|
||||||
s.ip as server_ip
|
|
||||||
FROM installed_scripts inst
|
|
||||||
LEFT JOIN servers s ON inst.server_id = s.id
|
|
||||||
WHERE inst.id = ?
|
|
||||||
`);
|
|
||||||
return stmt.get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} server_id
|
|
||||||
*/
|
|
||||||
getInstalledScriptsByServer(server_id) {
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT
|
|
||||||
inst.*,
|
|
||||||
s.name as server_name,
|
|
||||||
s.ip as server_ip
|
|
||||||
FROM installed_scripts inst
|
|
||||||
LEFT JOIN servers s ON inst.server_id = s.id
|
|
||||||
WHERE inst.server_id = ?
|
|
||||||
ORDER BY inst.installation_date DESC
|
|
||||||
`);
|
|
||||||
return stmt.all(server_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} id
|
|
||||||
* @param {Object} updateData
|
|
||||||
* @param {string} [updateData.script_name]
|
|
||||||
* @param {string} [updateData.container_id]
|
|
||||||
* @param {string} [updateData.status]
|
|
||||||
* @param {string} [updateData.output_log]
|
|
||||||
*/
|
|
||||||
updateInstalledScript(id, updateData) {
|
|
||||||
const { script_name, container_id, status, output_log } = updateData;
|
|
||||||
const updates = [];
|
|
||||||
const values = [];
|
|
||||||
|
|
||||||
if (script_name !== undefined) {
|
|
||||||
updates.push('script_name = ?');
|
|
||||||
values.push(script_name);
|
|
||||||
}
|
|
||||||
if (container_id !== undefined) {
|
|
||||||
updates.push('container_id = ?');
|
|
||||||
values.push(container_id);
|
|
||||||
}
|
|
||||||
if (status !== undefined) {
|
|
||||||
updates.push('status = ?');
|
|
||||||
values.push(status);
|
|
||||||
}
|
|
||||||
if (output_log !== undefined) {
|
|
||||||
updates.push('output_log = ?');
|
|
||||||
values.push(output_log);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.length === 0) {
|
|
||||||
return { changes: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
values.push(id);
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
UPDATE installed_scripts
|
|
||||||
SET ${updates.join(', ')}
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
return stmt.run(...values);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} id
|
|
||||||
*/
|
|
||||||
deleteInstalledScript(id) {
|
|
||||||
const stmt = this.db.prepare('DELETE FROM installed_scripts WHERE id = ?');
|
|
||||||
return stmt.run(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.db.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
/** @type {DatabaseService | null} */
|
|
||||||
let dbInstance = null;
|
|
||||||
|
|
||||||
export function getDatabase() {
|
|
||||||
if (!dbInstance) {
|
|
||||||
dbInstance = new DatabaseService();
|
|
||||||
}
|
|
||||||
return dbInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DatabaseService;
|
|
||||||
|
|
||||||
7
src/server/db.js
Normal file
7
src/server/db.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis;
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user