Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0e7e94a23 | ||
|
|
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 | ||
|
|
e09c1bbf5d | ||
|
|
a05185db1b | ||
|
|
a410aeacf7 | ||
|
|
7fd1351579 | ||
|
|
74b89575fe | ||
|
|
6b39fd7e0f | ||
|
|
2fcb267649 | ||
|
|
ec20e0322a | ||
|
|
b77554a7b5 | ||
|
|
d9271804dc | ||
|
|
5582d288d7 | ||
|
|
5823e54464 | ||
|
|
1557d589cf | ||
|
|
6fdd336be6 |
10
.env.example
10
.env.example
@@ -16,3 +16,13 @@ ALLOWED_SCRIPT_PATHS="scripts/"
|
||||
|
||||
# WebSocket Configuration
|
||||
WEBSOCKET_PORT="3001"
|
||||
|
||||
# User settings
|
||||
GITHUB_TOKEN=
|
||||
SAVE_FILTER=false
|
||||
FILTERS=
|
||||
AUTH_USERNAME=
|
||||
AUTH_PASSWORD_HASH=
|
||||
AUTH_ENABLED=false
|
||||
AUTH_SETUP_COMPLETED=false
|
||||
JWT_SECRET=
|
||||
40
.gitattributes
vendored
Normal file
40
.gitattributes
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Set default behavior to automatically normalize line endings
|
||||
* text=auto
|
||||
|
||||
# Shell scripts should always use LF
|
||||
*.sh text eol=lf
|
||||
*.func text eol=lf
|
||||
*.bash text eol=lf
|
||||
|
||||
# Windows batch files should use CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Configuration files should use LF
|
||||
*.conf text eol=lf
|
||||
*.config text eol=lf
|
||||
*.ini text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.json text eol=lf
|
||||
|
||||
# Source code files should use LF
|
||||
*.js text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
*.html text eol=lf
|
||||
*.xml text eol=lf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.db binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
16
.github/CODEOWNERS
vendored
Normal file
16
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
#
|
||||
# CODEOWNERS for ProxmoxVE
|
||||
#
|
||||
|
||||
# Order is important; the last matching pattern takes the most
|
||||
# precedence.
|
||||
|
||||
|
||||
# Codeowners for specific folders and files
|
||||
# Remember ending folders with /
|
||||
|
||||
|
||||
# Set default reviewers
|
||||
* @michelroegl-brunner
|
||||
* @community-scripts/Contributor
|
||||
|
||||
50
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
50
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: "🐞 Script Issue Report"
|
||||
description: Report a specific issue.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## ⚠️ **IMPORTANT - READ FIRST**
|
||||
- 🔍 **Search first:** Before submitting, check if the issue has already been reported or resolved in [closed issues](https://github.com/community-scripts/ProxmoxVE-Local/issues?q=is%3Aissue+is%3Aclosed). If found, comment on that issue instead of creating a new one.
|
||||
Thank you for taking the time to report an issue! Please provide as much detail as possible to help us address the problem efficiently.
|
||||
|
||||
|
||||
- type: input
|
||||
id: guidelines
|
||||
attributes:
|
||||
label: ✅ Have you read and understood the above guidelines?
|
||||
placeholder: "yes"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: issue_description
|
||||
attributes:
|
||||
label: 📝 Provide a clear and concise description of the issue.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps_to_reproduce
|
||||
attributes:
|
||||
label: 🔄 Steps to reproduce the issue.
|
||||
placeholder: "e.g., Step 1: ..., Step 2: ..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: error_output
|
||||
attributes:
|
||||
label: ❌ Paste the full error output (if available).
|
||||
placeholder: "Include any relevant logs or error messages."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: 🖼️ Additional context (optional).
|
||||
placeholder: "Include screenshots, code blocks (use triple backticks ```), or any other relevant information."
|
||||
validations:
|
||||
required: false
|
||||
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: "✨ Feature Request"
|
||||
description: "Suggest a new feature or enhancement."
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# ✨ **Feature Request**
|
||||
Have an idea for a new feature? Share your thoughts below!
|
||||
|
||||
- type: input
|
||||
id: feature_summary
|
||||
attributes:
|
||||
label: "🌟 Briefly describe the feature"
|
||||
placeholder: "e.g., Add support for XYZ"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: feature_description
|
||||
attributes:
|
||||
label: "📝 Detailed description"
|
||||
placeholder: "Explain the feature in detail"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use_case
|
||||
attributes:
|
||||
label: "💡 Why is this useful?"
|
||||
placeholder: "Describe the benefit of this feature"
|
||||
validations:
|
||||
required: true
|
||||
BIN
.github/logo.png
vendored
Normal file
BIN
.github/logo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
24
.github/pull_request_template.md
vendored
Normal file
24
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--🛑 All Pull Requests need to made against the development branch. PRs against main will get closed. -->
|
||||
## ✍️ Description
|
||||
|
||||
|
||||
|
||||
## 🔗 Related PR / Issue
|
||||
Link: #
|
||||
|
||||
|
||||
## ✅ Prerequisites (**X** in brackets)
|
||||
|
||||
- [ ] **Self-review completed** – Code follows project standards.
|
||||
- [ ] **Tested thoroughly** – Changes work as expected.
|
||||
- [ ] **No security risks** – No hardcoded secrets, unnecessary privilege escalations, or permission issues.
|
||||
|
||||
## Screenshot for frontend Change
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Type of Change (**X** in brackets)
|
||||
|
||||
- [ ] 🐞 **Bug fix** – Resolves an issue without breaking functionality.
|
||||
- [ ] ✨ **New feature** – Adds new, non-breaking functionality.
|
||||
- [ ] 💥 **Breaking change** – Alters existing functionality in a way that may require updates.
|
||||
14
.github/release-drafter.yml
vendored
14
.github/release-drafter.yml
vendored
@@ -1,6 +1,11 @@
|
||||
# Template for release drafts
|
||||
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
||||
tag-template: 'v$NEXT_PATCH_VERSION'
|
||||
name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
||||
tag-template: 'v$NEXT_MINOR_VERSION'
|
||||
|
||||
# Exclude PRs with this label from release notes
|
||||
exclude-labels:
|
||||
- automated
|
||||
|
||||
categories:
|
||||
- title: "🚀 Features"
|
||||
labels:
|
||||
@@ -13,6 +18,11 @@ categories:
|
||||
labels:
|
||||
- chore
|
||||
- refactor
|
||||
- title: "Dependencies"
|
||||
labels:
|
||||
- dependencies
|
||||
- javascript
|
||||
|
||||
change-template: '- $TITLE (#$NUMBER) by @$AUTHOR'
|
||||
change-title-template: '### $TITLE'
|
||||
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 }}
|
||||
1
.github/workflows/release-drafter.yml
vendored
1
.github/workflows/release-drafter.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update_release_draft:
|
||||
|
||||
162
README.md
162
README.md
@@ -1,14 +1,18 @@
|
||||
# PVE Scripts Local 🚀
|
||||
|
||||
A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming.
|
||||
A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming. No more need for curl -> bash calls, it all happens in your enviroment.
|
||||
|
||||
|
||||
<img width="1725" height="1088" alt="image" src="https://github.com/user-attachments/assets/75323765-7375-4346-a41e-08d219275248" />
|
||||
|
||||
|
||||
|
||||
## 🎯 Deployment Options
|
||||
|
||||
This application can be deployed in multiple ways to suit different environments:
|
||||
|
||||
- **🐧 Proxmox Host**: Run directly on your Proxmox VE host system
|
||||
- **📦 Debian LXC Container**: Deploy inside a Debian LXC container for better isolation
|
||||
- **⚡ Quick Install**: Use the automated `install.sh` script for easy setup
|
||||
- **🔧 Helper Script**: Use the automated helper script for easy setup
|
||||
|
||||
All deployment methods provide the same functionality and web interface.
|
||||
|
||||
@@ -57,9 +61,6 @@ All deployment methods provide the same functionality and web interface.
|
||||
- **Proxmox VE environment** (host or access to Proxmox cluster)
|
||||
- **SQLite** (included with Node.js better-sqlite3 package)
|
||||
|
||||
### For Proxmox Host Installation
|
||||
- **build-essentials**: `apt install build-essential`
|
||||
- Direct access to Proxmox host system
|
||||
|
||||
### For Debian LXC Container Installation
|
||||
- **Debian LXC container** (Debian 11+ recommended)
|
||||
@@ -68,40 +69,11 @@ All deployment methods provide the same functionality and web interface.
|
||||
- Network access from container to Proxmox host
|
||||
- Optional: Privileged container for full Proxmox integration
|
||||
|
||||
### For Quick Install (install.sh)
|
||||
- **Proxmox VE host** (script automatically detects and configures)
|
||||
- Internet connectivity for downloading dependencies
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
Choose the installation method that best fits your environment:
|
||||
|
||||
### Option 1: Quick Install with install.sh (Recommended for Proxmox Host)
|
||||
|
||||
Run this command directly on your Proxmox VE host or on any Debian based lxc:
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)"
|
||||
```
|
||||
|
||||
**What the script does:**
|
||||
- ✅ Installs required dependencies (build-essential, git, Node.js 24.x)
|
||||
- ✅ Clones the repository into `/opt/PVESciptslocal` (or your chosen path)
|
||||
- ✅ Runs npm install and builds the project
|
||||
- ✅ Sets up `.env` from `.env.example` if missing
|
||||
- ✅ Creates database directory (`data/`) for SQLite storage
|
||||
- ✅ Creates a systemd service (`pvescriptslocal.service`) for easy management
|
||||
|
||||
**After installation:**
|
||||
- 🌐 Access the app at: `http://<YOUR_PVE_OR_LXC_IP>:3000`
|
||||
- 🔧 Manage the service with:
|
||||
```bash
|
||||
systemctl start pvescriptslocal
|
||||
systemctl stop pvescriptslocal
|
||||
systemctl status pvescriptslocal
|
||||
```
|
||||
|
||||
### Option 2: Debian LXC Container Installation
|
||||
### Option 1: Debian LXC Container Installation
|
||||
|
||||
For better isolation and security, you can run PVE Scripts Local inside a Debian LXC container:
|
||||
|
||||
@@ -110,13 +82,8 @@ For better isolation and security, you can run PVE Scripts Local inside a Debian
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/debian.sh)"
|
||||
```
|
||||
Then run the installer:
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)"
|
||||
```
|
||||
|
||||
#### Step 2: Install Dependencies in Container when installer is not used
|
||||
#### Step 2: Install Dependencies in Container
|
||||
```bash
|
||||
# Enter the container
|
||||
pct enter 100
|
||||
@@ -132,15 +99,13 @@ apt install -y nodejs
|
||||
#### Step 3: Clone and Setup Application
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/michelroegl-brunner/PVESciptslocal.git /opt/PVESciptslocal
|
||||
cd /opt/PVESciptslocal
|
||||
git clone https://github.com/community-scripts/ProxmoxVE-Local.git /opt/PVESciptslocal
|
||||
cd PVESciptslocal
|
||||
|
||||
# Install dependencies and build
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# Setup environment
|
||||
cp .env.example .env
|
||||
npm run build
|
||||
|
||||
# Create database directory
|
||||
mkdir -p data
|
||||
@@ -153,89 +118,33 @@ chmod 755 data
|
||||
npm start
|
||||
|
||||
# Or create a systemd service (optional)
|
||||
# Follow the same systemd setup as the install.sh script
|
||||
# Create systemd service for easy management
|
||||
```
|
||||
|
||||
**Access the application:**
|
||||
- 🌐 Container IP: `http://<CONTAINER_IP>:3000`
|
||||
- 🔧 Container management: `pct start 100`, `pct stop 100`, `pct status 100`
|
||||
|
||||
### Option 3: Manual Installation (Proxmox Host)
|
||||
### Option 2: Use the helper script
|
||||
|
||||
This creates the LXC and installs the APP for you.
|
||||
|
||||
#### Step 1: Clone the Repository
|
||||
```bash
|
||||
git clone https://github.com/michelroegl-brunner/PVESciptslocal.git
|
||||
cd PVESciptslocal
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/pve-scripts-local.sh)"
|
||||
```
|
||||
|
||||
#### Step 2: Install Dependencies
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
#### Step 3: Environment Configuration
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env file with your specific settings if needed
|
||||
```
|
||||
|
||||
#### Step 4: Database Setup
|
||||
```bash
|
||||
# Create database directory
|
||||
mkdir -p data
|
||||
chmod 755 data
|
||||
```
|
||||
|
||||
#### Step 5: Build and Start
|
||||
```bash
|
||||
# Production mode
|
||||
npm run build
|
||||
npm start
|
||||
|
||||
# Development mode
|
||||
npm run dev:server
|
||||
```
|
||||
|
||||
**Access the application:**
|
||||
- 🌐 Available at: `http://<YOUR_IP>:3000`
|
||||
|
||||
## 📝 LXC Container Specific Notes
|
||||
|
||||
### Container Requirements
|
||||
- **OS**: Debian 11+ (Debian 12 recommended)
|
||||
- **Resources**: Minimum 2GB RAM, 4GB storage
|
||||
- **Network**: Bridge connection to Proxmox network
|
||||
- **Privileges**: Unprivileged containers work, but privileged containers provide better Proxmox integration
|
||||
|
||||
### Container Configuration Tips
|
||||
- **Privileged Container**: Use `--unprivileged 0` for full Proxmox API access
|
||||
- **Resource Allocation**: Allocate at least 2 CPU cores and 2GB RAM for optimal performance
|
||||
- **Storage**: Use at least 8GB for the container to accommodate Node.js and dependencies
|
||||
- **Network**: Ensure the container can reach the Proxmox host API
|
||||
|
||||
### Security Considerations
|
||||
- **Unprivileged Containers**: More secure but may have limited Proxmox functionality
|
||||
- **Privileged Containers**: Full Proxmox access but less secure isolation
|
||||
- **Network Access**: Ensure proper firewall rules for the container
|
||||
|
||||
### Troubleshooting LXC Installation
|
||||
- **Permission Issues**: Ensure the container has proper permissions for Proxmox API access
|
||||
- **Network Connectivity**: Verify the container can reach the Proxmox host
|
||||
- **Resource Limits**: Check if the container has sufficient resources allocated
|
||||
|
||||
## 🎯 Usage
|
||||
|
||||
### 1. Access the Web Interface
|
||||
|
||||
The web interface is accessible regardless of your deployment method:
|
||||
|
||||
- **Proxmox Host Installation**: `http://<PROXMOX_HOST_IP>:3000`
|
||||
- **LXC Container Installation**: `http://<CONTAINER_IP>:3000`
|
||||
- **Custom Installation**: `http://<YOUR_IP>:3000`
|
||||
|
||||
### 2. Service Management
|
||||
|
||||
#### For install.sh installations (systemd service):
|
||||
#### For helper-script installations (systemd service):
|
||||
```bash
|
||||
# Start the service
|
||||
systemctl start pvescriptslocal
|
||||
@@ -253,20 +162,6 @@ systemctl enable pvescriptslocal
|
||||
journalctl -u pvescriptslocal -f
|
||||
```
|
||||
|
||||
#### For LXC container installations:
|
||||
```bash
|
||||
# Container management
|
||||
pct start <container_id> # Start container
|
||||
pct stop <container_id> # Stop container
|
||||
pct status <container_id> # Check container status
|
||||
|
||||
# Access container shell
|
||||
pct enter <container_id>
|
||||
|
||||
# Inside container - start application
|
||||
cd /opt/PVESciptslocal
|
||||
npm start
|
||||
```
|
||||
|
||||
#### For manual installations:
|
||||
```bash
|
||||
@@ -289,7 +184,7 @@ npm run build
|
||||
### 4. Download Scripts
|
||||
|
||||
- Click on any script card to view details
|
||||
- Use the "Download" button to fetch scripts from GitHub
|
||||
- Use the "Download" button to fetch scripts from the ProxmoxVE GitHub
|
||||
- Downloaded scripts are stored locally in the `scripts/` directory
|
||||
|
||||
### 5. Execute Scripts
|
||||
@@ -379,23 +274,6 @@ npm run dev:server
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
### Logs
|
||||
|
||||
- Server logs: Check console output or `server.log`
|
||||
- Script execution: View in web terminal
|
||||
|
||||
## 🎯 Quick Start Summary
|
||||
|
||||
Choose your preferred deployment method:
|
||||
|
||||
| Method | Best For | Command |
|
||||
|--------|----------|---------|
|
||||
| **Quick Install** | Proxmox hosts or Debian LXC, easy setup | `bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)"` |
|
||||
| **LXC Container** | Better isolation, security | Create Debian LXC → Install dependencies → Clone repo → `npm start` |
|
||||
| **Manual Install** | Custom setups, development | `git clone` → `npm install` → `npm run build` → `npm start` |
|
||||
|
||||
All methods provide the same web interface at `http://<IP>:3000` with full Proxmox script management capabilities.
|
||||
|
||||
---
|
||||
|
||||
**Note**: This is alpha software. Use with caution in production environments and always backup your Proxmox configuration before running scripts.
|
||||
**Note**: This is beta software. Use with caution in production environments and always backup your Proxmox configuration before running scripts.
|
||||
|
||||
136
install.sh
136
install.sh
@@ -1,136 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ------------------------------------------------------------------------------
|
||||
# Installer for PVESciptslocal with systemd integration
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
# --- Core ---------------------------------------------------------------------
|
||||
RD=$(echo -e "\033[01;31m")
|
||||
GN=$(echo -e "\033[1;92m")
|
||||
YW=$(echo -e "\033[33m")
|
||||
CL=$(echo -e "\033[m")
|
||||
|
||||
msg_info() { echo -e "⏳ $YW$1$CL"; }
|
||||
msg_ok() { echo -e "✔️ $GN$1$CL"; }
|
||||
msg_err() { echo -e "❌ $RD$1$CL"; }
|
||||
|
||||
|
||||
|
||||
# --- Dependency Check & Install -----------------------------------------------
|
||||
check_dependencies() {
|
||||
msg_info "Checking required packages (build-essential, git)..."
|
||||
apt-get update
|
||||
apt-get install -y build-essential git sshpass expect
|
||||
msg_ok "Dependencies installed."
|
||||
}
|
||||
|
||||
check_nodejs() {
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
msg_info "Node.js not found, installing Node.js 24.x..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
|
||||
apt-get install -y nodejs
|
||||
msg_ok "Node.js installed: $(node -v)"
|
||||
else
|
||||
msg_ok "Node.js already available: $(node -v)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Repository Handling ------------------------------------------------------
|
||||
clone_or_update_repo() {
|
||||
read -rp "Installation directory [default: /opt/PVESciptslocal]: " INSTALL_DIR
|
||||
INSTALL_DIR=${INSTALL_DIR:-/opt/PVESciptslocal}
|
||||
|
||||
if [ ! -d "$INSTALL_DIR/.git" ]; then
|
||||
msg_info "Cloning repository into $INSTALL_DIR..."
|
||||
git clone https://github.com/michelroegl-brunner/PVESciptslocal.git "$INSTALL_DIR"
|
||||
msg_ok "Repository cloned."
|
||||
else
|
||||
msg_info "Directory already exists. Pulling latest changes..."
|
||||
git -C "$INSTALL_DIR" pull
|
||||
msg_ok "Repository updated."
|
||||
fi
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
}
|
||||
|
||||
# --- Application Setup --------------------------------------------------------
|
||||
setup_app() {
|
||||
msg_info "Installing npm dependencies..."
|
||||
npm install
|
||||
msg_ok "Dependencies installed."
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
msg_info "Creating environment file from example..."
|
||||
cp .env.example .env
|
||||
msg_ok ".env file created."
|
||||
else
|
||||
msg_ok ".env file already exists, keeping it."
|
||||
fi
|
||||
|
||||
msg_info "Setting up database directory..."
|
||||
mkdir -p data
|
||||
chmod 755 data
|
||||
msg_ok "Database directory created."
|
||||
|
||||
msg_info "Building application..."
|
||||
npm run build
|
||||
msg_ok "Build completed."
|
||||
}
|
||||
|
||||
# --- Systemd Service ----------------------------------------------------------
|
||||
setup_systemd_service() {
|
||||
SERVICE_NAME="pvescriptslocal"
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
msg_info "Creating systemd service at $SERVICE_FILE..."
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=PVEScriptslocal Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=/usr/bin/npm start
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=NODE_ENV=production
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reexec
|
||||
msg_ok "Systemd service created."
|
||||
|
||||
read -rp "Enable and start the service now? (y/N): " START_SERVICE
|
||||
if [[ "$START_SERVICE" =~ ^[Yy]$ ]]; then
|
||||
systemctl enable --now "$SERVICE_NAME"
|
||||
msg_ok "Service enabled and started."
|
||||
else
|
||||
msg_info "You can start it manually with: systemctl start $SERVICE_NAME"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "---------------------------------------------"
|
||||
echo " Service installed: $SERVICE_NAME"
|
||||
echo " Manage it with:"
|
||||
echo " systemctl start $SERVICE_NAME"
|
||||
echo " systemctl stop $SERVICE_NAME"
|
||||
echo " systemctl status $SERVICE_NAME"
|
||||
echo " App will be available at: http://<IP>:3000"
|
||||
echo "---------------------------------------------"
|
||||
}
|
||||
|
||||
# --- Main ---------------------------------------------------------------------
|
||||
main() {
|
||||
check_pve
|
||||
check_dependencies
|
||||
check_nodejs
|
||||
clone_or_update_repo
|
||||
setup_app
|
||||
setup_systemd_service
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -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;
|
||||
|
||||
1899
package-lock.json
generated
1899
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@@ -22,17 +22,23 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@tanstack/react-query": "^5.87.4",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next": "^15.5.3",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -42,29 +48,32 @@
|
||||
"server-only": "^0.0.1",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.7.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-config-next": "^15.2.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"eslint-config-next": "^15.5.4",
|
||||
"jsdom": "^27.0.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.27.0",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
@@ -23,7 +23,7 @@
|
||||
"ram": 512,
|
||||
"hdd": 2,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 2048,
|
||||
"hdd": 4,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE LXC Tag",
|
||||
"name": "PVE LXC Tag",
|
||||
"slug": "add-iptag",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 512,
|
||||
"hdd": 2,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "Adguard Home can be updated via the user interface.",
|
||||
"text": "AdGuard Home can only be updated via the user interface.",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 2048,
|
||||
"hdd": 7,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 2048,
|
||||
"hdd": 4,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"script": "ct/booklore.sh",
|
||||
"resources": {
|
||||
"cpu": 3,
|
||||
"ram": 2048,
|
||||
"ram": 3072,
|
||||
"hdd": 7,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"categories": [
|
||||
21
|
||||
],
|
||||
"date_created": "2024-05-11",
|
||||
"date_created": "2025-09-17",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
@@ -21,10 +21,21 @@
|
||||
"resources": {
|
||||
"cpu": 1,
|
||||
"ram": 512,
|
||||
"hdd": 4,
|
||||
"hdd": 6,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "alpine",
|
||||
"script": "ct/alpine-caddy.sh",
|
||||
"resources": {
|
||||
"cpu": 1,
|
||||
"ram": 256,
|
||||
"hdd": 3,
|
||||
"os": "alpine",
|
||||
"version": "3.22"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE LXC Cleaner",
|
||||
"name": "PVE LXC Cleaner",
|
||||
"slug": "clean-lxcs",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox Clean Orphaned LVM",
|
||||
"name": "PVE Clean Orphaned LVM",
|
||||
"slug": "clean-orphaned-lvm",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 512,
|
||||
"hdd": 2,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 1024,
|
||||
"hdd": 4,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -32,5 +32,10 @@
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": []
|
||||
"notes": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "The file `/etc/sysconfig/CosmosCloud` is optional. If you need custom settings, you can create it yourself."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE Cron LXC Updater",
|
||||
"name": "PVE Cron LXC Updater",
|
||||
"slug": "cron-update-lxcs",
|
||||
"categories": [
|
||||
1
|
||||
@@ -13,7 +13,7 @@
|
||||
"website": null,
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
|
||||
"config_path": "",
|
||||
"description": "This script will add/remove a crontab schedule that updates all LXCs every Sunday at midnight.",
|
||||
"description": "This script will add/remove a crontab schedule that updates the operating system of all LXCs every Sunday at midnight.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 512,
|
||||
"hdd": 2,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
{
|
||||
"type": "info",
|
||||
"text": "Synapse-Admin is running on port 5173"
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "For bridges Installation methods (WhatsApp, Signal, Discord, etc.), see: ´https://docs.mau.fi/bridges/go/setup.html´"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE LXC Filesystem Trim",
|
||||
"name": "PVE LXC Filesystem Trim",
|
||||
"slug": "fstrim",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
52
scripts/json/ghostfolio.json
Normal file
52
scripts/json/ghostfolio.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "Ghostfolio",
|
||||
"slug": "ghostfolio",
|
||||
"categories": [
|
||||
23
|
||||
],
|
||||
"date_created": "2025-09-29",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": 3333,
|
||||
"documentation": "https://github.com/ghostfolio/ghostfolio?tab=readme-ov-file#self-hosting",
|
||||
"website": "https://ghostfol.io/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/ghostfolio.webp",
|
||||
"config_path": "/opt/ghostfolio/.env",
|
||||
"description": "Ghostfolio is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/ghostfolio.sh",
|
||||
"resources": {
|
||||
"cpu": 2,
|
||||
"ram": 4096,
|
||||
"hdd": 8,
|
||||
"os": "debian",
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "Create your first user account by visiting the web interface and clicking 'Get Started'. The first user will automatically get admin privileges.",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"text": "Database and Redis credentials: `cat ~/ghostfolio.creds`",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"text": "Optional: CoinGecko API keys can be added during installation or later in the .env file for enhanced cryptocurrency data.",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"text": "Build process requires 4GB RAM (runtime: ~2GB). A temporary swap file will be created automatically if insufficient memory is detected.",
|
||||
"type": "warning"
|
||||
}
|
||||
]
|
||||
}
|
||||
35
scripts/json/globaleaks.json
Normal file
35
scripts/json/globaleaks.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "GlobaLeaks",
|
||||
"slug": "globaleaks",
|
||||
"categories": [
|
||||
0
|
||||
],
|
||||
"date_created": "2025-09-18",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": 443,
|
||||
"documentation": "https://docs.globaleaks.org",
|
||||
"website": "https://www.globaleaks.org/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/globaleaks.webp",
|
||||
"config_path": "",
|
||||
"description": "GlobaLeaks is a free and open-source whistleblowing software enabling anyone to easily set up and maintain a secure reporting platform.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/globaleaks.sh",
|
||||
"resources": {
|
||||
"cpu": 2,
|
||||
"ram": 1024,
|
||||
"hdd": 4,
|
||||
"os": "debian",
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": []
|
||||
}
|
||||
40
scripts/json/goaway.json
Normal file
40
scripts/json/goaway.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "GoAway",
|
||||
"slug": "goaway",
|
||||
"categories": [
|
||||
5
|
||||
],
|
||||
"date_created": "2025-09-25",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": 8080,
|
||||
"documentation": "https://github.com/pommee/goaway#configuration-file",
|
||||
"config_path": "/opt/goaway/config/settings.yaml",
|
||||
"website": "https://github.com/pommee/goaway",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/goaway.webp",
|
||||
"description": "Lightweight DNS sinkhole written in Go with a modern dashboard client. Very good looking new alternative to Pi-Hole and Adguard Home.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/goaway.sh",
|
||||
"resources": {
|
||||
"cpu": 1,
|
||||
"ram": 1024,
|
||||
"hdd": 4,
|
||||
"os": "Debian",
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "Type `cat ~/goaway.creds` to see login credentials.",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"website": "https://www.getgrist.com/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/grist.webp",
|
||||
"config_path": "/opt/grist/.env",
|
||||
"description": "Grist is a modern, open source spreadsheet that goes beyond the grid",
|
||||
"description": "Grist is like a spreadsheet + database hybrid. It lets you store structured data, use relational links between tables, apply formulas (even with Python), build custom layouts (cards, forms, dashboards), set fine-grained access rules, and visualize data with charts or pivot-tables.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
"password": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "Containerized version doesn't allow Home Assistant add-ons.",
|
||||
"type": "warning"
|
||||
},
|
||||
{
|
||||
"text": "If the LXC is created Privileged, the script will automatically set up USB passthrough.",
|
||||
"type": "warning"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE Host Backup",
|
||||
"name": "PVE Host Backup",
|
||||
"slug": "host-backup",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 4096,
|
||||
"hdd": 20,
|
||||
"os": "Debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
40
scripts/json/joplin-server.json
Normal file
40
scripts/json/joplin-server.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "Joplin Server",
|
||||
"slug": "joplin-server",
|
||||
"categories": [
|
||||
12
|
||||
],
|
||||
"date_created": "2025-09-24",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": 22300,
|
||||
"documentation": "https://joplinapp.org/help/",
|
||||
"config_path": "/opt/joplin-server/.env",
|
||||
"website": "https://joplinapp.org/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/joplin.webp",
|
||||
"description": "Joplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/joplin-server.sh",
|
||||
"resources": {
|
||||
"cpu": 2,
|
||||
"ram": 4096,
|
||||
"hdd": 20,
|
||||
"os": "Debian",
|
||||
"version": "12"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": "admin@localhost",
|
||||
"password": "admin"
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "Application can take some time to build, depending on your host speed. Please be patient.",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE Kernel Clean",
|
||||
"name": "PVE Kernel Clean",
|
||||
"slug": "kernel-clean",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE Kernel Pin",
|
||||
"name": "PVE Kernel Pin",
|
||||
"slug": "kernel-pin",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 2048,
|
||||
"hdd": 4,
|
||||
"os": "Debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Container LXC Deletion",
|
||||
"name": "PVE LXC Deletion",
|
||||
"slug": "lxc-delete",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
48
scripts/json/lxc-execute.json
Normal file
48
scripts/json/lxc-execute.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "PVE LXC Execute Command",
|
||||
"slug": "lxc-execute",
|
||||
"categories": [
|
||||
1
|
||||
],
|
||||
"date_created": "2025-09-18",
|
||||
"type": "pve",
|
||||
"updateable": false,
|
||||
"privileged": false,
|
||||
"interface_port": null,
|
||||
"documentation": null,
|
||||
"website": null,
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
|
||||
"config_path": "",
|
||||
"description": "This script allows administrators to execute a custom command inside one or multiple LXC containers on a Proxmox VE node. Containers can be selectively excluded via an interactive checklist. If a container is stopped, the script will automatically start it, run the command, and then shut it down again. Only Debian and Ubuntu based containers are supported.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "tools/pve/execute.sh",
|
||||
"resources": {
|
||||
"cpu": null,
|
||||
"ram": null,
|
||||
"hdd": null,
|
||||
"os": null,
|
||||
"version": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "Execute within the Proxmox shell.",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"text": "Non-Debian/Ubuntu containers will be skipped automatically.",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"text": "Stopped containers will be started temporarily to run the command, then shut down again.",
|
||||
"type": "warning"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,31 +1,186 @@
|
||||
{
|
||||
"categories": [
|
||||
{ "name": "Proxmox & Virtualization", "id": 1, "sort_order": 1.0, "description": "Tools and scripts to manage Proxmox VE and virtualization platforms effectively." },
|
||||
{ "name": "Operating Systems", "id": 2, "sort_order": 2.0, "description": "Scripts for deploying and managing various operating systems." },
|
||||
{ "name": "Containers & Docker", "id": 3, "sort_order": 3.0, "description": "Solutions for containerization using Docker and related technologies." },
|
||||
{ "name": "Network & Firewall", "id": 4, "sort_order": 4.0, "description": "Enhance network security and configure firewalls with ease." },
|
||||
{ "name": "Adblock & DNS", "id": 5, "sort_order": 5.0, "description": "Optimize your network with DNS and ad-blocking solutions." },
|
||||
{ "name": "Authentication & Security", "id": 6, "sort_order": 6.0, "description": "Secure your infrastructure with authentication and security tools." },
|
||||
{ "name": "Backup & Recovery", "id": 7, "sort_order": 7.0, "description": "Reliable backup and recovery scripts to protect your data." },
|
||||
{ "name": "Databases", "id": 8, "sort_order": 8.0, "description": "Deploy and manage robust database systems with ease." },
|
||||
{ "name": "Monitoring & Analytics", "id": 9, "sort_order": 9.0, "description": "Monitor system performance and analyze data seamlessly." },
|
||||
{ "name": "Dashboards & Frontends", "id": 10, "sort_order": 10.0, "description": "Create interactive dashboards and user-friendly frontends." },
|
||||
{ "name": "Files & Downloads", "id": 11, "sort_order": 11.0, "description": "Manage file sharing and downloading solutions efficiently." },
|
||||
{ "name": "Documents & Notes", "id": 12, "sort_order": 12.0, "description": "Organize and manage documents and note-taking tools." },
|
||||
{ "name": "Media & Streaming", "id": 13, "sort_order": 13.0, "description": "Stream and manage media effortlessly across devices." },
|
||||
{ "name": "*Arr Suite", "id": 14, "sort_order": 14.0, "description": "Automated media management with the popular *Arr suite tools." },
|
||||
{ "name": "NVR & Cameras", "id": 15, "sort_order": 15.0, "description": "Manage network video recorders and camera setups." },
|
||||
{ "name": "IoT & Smart Home", "id": 16, "sort_order": 16.0, "description": "Control and automate IoT devices and smart home systems." },
|
||||
{ "name": "ZigBee, Z-Wave & Matter", "id": 17, "sort_order": 17.0, "description": "Solutions for ZigBee, Z-Wave, and Matter-based device management." },
|
||||
{ "name": "MQTT & Messaging", "id": 18, "sort_order": 18.0, "description": "Set up reliable messaging and MQTT-based communication systems." },
|
||||
{ "name": "Automation & Scheduling", "id": 19, "sort_order": 19.0, "description": "Automate tasks and manage scheduling with powerful tools." },
|
||||
{ "name": "AI / Coding & Dev-Tools", "id": 20, "sort_order": 20.0, "description": "Leverage AI and developer tools for smarter coding workflows." },
|
||||
{ "name": "Webservers & Proxies", "id": 21, "sort_order": 21.0, "description": "Deploy and configure web servers and proxy solutions." },
|
||||
{ "name": "Bots & ChatOps", "id": 22, "sort_order": 22.0, "description": "Enhance collaboration with bots and ChatOps integrations." },
|
||||
{ "name": "Finance & Budgeting", "id": 23, "sort_order": 23.0, "description": "Track expenses and manage budgets efficiently." },
|
||||
{ "name": "Gaming & Leisure", "id": 24, "sort_order": 24.0, "description": "Scripts for gaming servers and leisure-related tools." },
|
||||
{ "name": "Business & ERP", "id": 25, "sort_order": 25.0, "description": "Streamline business operations with ERP and management tools." },
|
||||
{ "name": "Miscellaneous", "id": 0, "sort_order": 99.0, "description": "General scripts and tools that don't fit into other categories." }
|
||||
{
|
||||
"name": "Proxmox & Virtualization",
|
||||
"id": 1,
|
||||
"sort_order": 1.0,
|
||||
"description": "Tools and scripts to manage Proxmox VE and virtualization platforms effectively.",
|
||||
"icon": "server"
|
||||
},
|
||||
{
|
||||
"name": "Operating Systems",
|
||||
"id": 2,
|
||||
"sort_order": 2.0,
|
||||
"description": "Scripts for deploying and managing various operating systems.",
|
||||
"icon": "monitor"
|
||||
},
|
||||
{
|
||||
"name": "Containers & Docker",
|
||||
"id": 3,
|
||||
"sort_order": 3.0,
|
||||
"description": "Solutions for containerization using Docker and related technologies.",
|
||||
"icon": "box"
|
||||
},
|
||||
{
|
||||
"name": "Network & Firewall",
|
||||
"id": 4,
|
||||
"sort_order": 4.0,
|
||||
"description": "Enhance network security and configure firewalls with ease.",
|
||||
"icon": "shield"
|
||||
},
|
||||
{
|
||||
"name": "Adblock & DNS",
|
||||
"id": 5,
|
||||
"sort_order": 5.0,
|
||||
"description": "Optimize your network with DNS and ad-blocking solutions.",
|
||||
"icon": "ban"
|
||||
},
|
||||
{
|
||||
"name": "Authentication & Security",
|
||||
"id": 6,
|
||||
"sort_order": 6.0,
|
||||
"description": "Secure your infrastructure with authentication and security tools.",
|
||||
"icon": "lock"
|
||||
},
|
||||
{
|
||||
"name": "Backup & Recovery",
|
||||
"id": 7,
|
||||
"sort_order": 7.0,
|
||||
"description": "Reliable backup and recovery scripts to protect your data.",
|
||||
"icon": "archive"
|
||||
},
|
||||
{
|
||||
"name": "Databases",
|
||||
"id": 8,
|
||||
"sort_order": 8.0,
|
||||
"description": "Deploy and manage robust database systems with ease.",
|
||||
"icon": "database"
|
||||
},
|
||||
{
|
||||
"name": "Monitoring & Analytics",
|
||||
"id": 9,
|
||||
"sort_order": 9.0,
|
||||
"description": "Monitor system performance and analyze data seamlessly.",
|
||||
"icon": "bar-chart"
|
||||
},
|
||||
{
|
||||
"name": "Dashboards & Frontends",
|
||||
"id": 10,
|
||||
"sort_order": 10.0,
|
||||
"description": "Create interactive dashboards and user-friendly frontends.",
|
||||
"icon": "layout"
|
||||
},
|
||||
{
|
||||
"name": "Files & Downloads",
|
||||
"id": 11,
|
||||
"sort_order": 11.0,
|
||||
"description": "Manage file sharing and downloading solutions efficiently.",
|
||||
"icon": "download"
|
||||
},
|
||||
{
|
||||
"name": "Documents & Notes",
|
||||
"id": 12,
|
||||
"sort_order": 12.0,
|
||||
"description": "Organize and manage documents and note-taking tools.",
|
||||
"icon": "file-text"
|
||||
},
|
||||
{
|
||||
"name": "Media & Streaming",
|
||||
"id": 13,
|
||||
"sort_order": 13.0,
|
||||
"description": "Stream and manage media effortlessly across devices.",
|
||||
"icon": "play"
|
||||
},
|
||||
{
|
||||
"name": "*Arr Suite",
|
||||
"id": 14,
|
||||
"sort_order": 14.0,
|
||||
"description": "Automated media management with the popular *Arr suite tools.",
|
||||
"icon": "tv"
|
||||
},
|
||||
{
|
||||
"name": "NVR & Cameras",
|
||||
"id": 15,
|
||||
"sort_order": 15.0,
|
||||
"description": "Manage network video recorders and camera setups.",
|
||||
"icon": "camera"
|
||||
},
|
||||
{
|
||||
"name": "IoT & Smart Home",
|
||||
"id": 16,
|
||||
"sort_order": 16.0,
|
||||
"description": "Control and automate IoT devices and smart home systems.",
|
||||
"icon": "home"
|
||||
},
|
||||
{
|
||||
"name": "ZigBee, Z-Wave & Matter",
|
||||
"id": 17,
|
||||
"sort_order": 17.0,
|
||||
"description": "Solutions for ZigBee, Z-Wave, and Matter-based device management.",
|
||||
"icon": "radio"
|
||||
},
|
||||
{
|
||||
"name": "MQTT & Messaging",
|
||||
"id": 18,
|
||||
"sort_order": 18.0,
|
||||
"description": "Set up reliable messaging and MQTT-based communication systems.",
|
||||
"icon": "message-circle"
|
||||
},
|
||||
{
|
||||
"name": "Automation & Scheduling",
|
||||
"id": 19,
|
||||
"sort_order": 19.0,
|
||||
"description": "Automate tasks and manage scheduling with powerful tools.",
|
||||
"icon": "clock"
|
||||
},
|
||||
{
|
||||
"name": "AI / Coding & Dev-Tools",
|
||||
"id": 20,
|
||||
"sort_order": 20.0,
|
||||
"description": "Leverage AI and developer tools for smarter coding workflows.",
|
||||
"icon": "code"
|
||||
},
|
||||
{
|
||||
"name": "Webservers & Proxies",
|
||||
"id": 21,
|
||||
"sort_order": 21.0,
|
||||
"description": "Deploy and configure web servers and proxy solutions.",
|
||||
"icon": "globe"
|
||||
},
|
||||
{
|
||||
"name": "Bots & ChatOps",
|
||||
"id": 22,
|
||||
"sort_order": 22.0,
|
||||
"description": "Enhance collaboration with bots and ChatOps integrations.",
|
||||
"icon": "bot"
|
||||
},
|
||||
{
|
||||
"name": "Finance & Budgeting",
|
||||
"id": 23,
|
||||
"sort_order": 23.0,
|
||||
"description": "Track expenses and manage budgets efficiently.",
|
||||
"icon": "dollar-sign"
|
||||
},
|
||||
{
|
||||
"name": "Gaming & Leisure",
|
||||
"id": 24,
|
||||
"sort_order": 24.0,
|
||||
"description": "Scripts for gaming servers and leisure-related tools.",
|
||||
"icon": "gamepad-2"
|
||||
},
|
||||
{
|
||||
"name": "Business & ERP",
|
||||
"id": 25,
|
||||
"sort_order": 25.0,
|
||||
"description": "Streamline business operations with ERP and management tools.",
|
||||
"icon": "building"
|
||||
},
|
||||
{
|
||||
"name": "Miscellaneous",
|
||||
"id": 0,
|
||||
"sort_order": 99.0,
|
||||
"description": "General scripts and tools that don't fit into other categories.",
|
||||
"icon": "more-horizontal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE Processor Microcode",
|
||||
"name": "PVE Processor Microcode",
|
||||
"slug": "microcode",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE Monitor-All",
|
||||
"name": "PVE Monitor-All",
|
||||
"slug": "monitor-all",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
35
scripts/json/myip.json
Normal file
35
scripts/json/myip.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "MyIP",
|
||||
"slug": "myip",
|
||||
"categories": [
|
||||
4
|
||||
],
|
||||
"date_created": "2025-09-29",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"config_path": "/opt/myip/.env",
|
||||
"interface_port": 18966,
|
||||
"documentation": "https://github.com/jason5ng32/MyIP#-environment-variable",
|
||||
"website": "https://ipcheck.ing/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/myip.webp",
|
||||
"description": "The best IP Toolbox. Easy to check what's your IPs, IP geolocation, check for DNS leaks, examine WebRTC connections, speed test, ping test, MTR test, check website availability, whois search and more!",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/myip.sh",
|
||||
"resources": {
|
||||
"cpu": 1,
|
||||
"ram": 512,
|
||||
"hdd": 2,
|
||||
"os": "Debian",
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": []
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 1024,
|
||||
"hdd": 4,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE Netdata",
|
||||
"name": "PVE Netdata",
|
||||
"slug": "netdata",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 1024,
|
||||
"hdd": 4,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 1024,
|
||||
"hdd": 4,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 512,
|
||||
"hdd": 2,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -31,5 +31,10 @@
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": []
|
||||
"notes": [
|
||||
{
|
||||
"text": "Script contains optional installation of Ollama.",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
"script": "ct/overseerr.sh",
|
||||
"resources": {
|
||||
"cpu": 2,
|
||||
"ram": 2048,
|
||||
"ram": 4096,
|
||||
"hdd": 8,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox Backup Server Processor Microcode",
|
||||
"name": "PBS Processor Microcode",
|
||||
"slug": "pbs-microcode",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
44
scripts/json/phpmyadmin.json
Normal file
44
scripts/json/phpmyadmin.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "PhpMyAdmin",
|
||||
"slug": "phpmyadmin",
|
||||
"categories": [
|
||||
8
|
||||
],
|
||||
"date_created": "2025-10-01",
|
||||
"type": "addon",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": null,
|
||||
"documentation": "https://www.phpmyadmin.net/docs/",
|
||||
"config_path": "Debian/Ubuntu: /var/www/html/phpMyAdmin | Alpine: /usr/share/phpmyadmin",
|
||||
"website": "https://www.phpmyadmin.net/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/phpmyadmin.webp",
|
||||
"description": "phpMyAdmin is a free software tool written in PHP, intended to handle the administration of MySQL over the Web. phpMyAdmin supports a wide range of operations on MySQL and MariaDB. Frequently used operations (managing databases, tables, columns, relations, indexes, users, permissions, etc) can be performed via the user interface, while you still have the ability to directly execute any SQL statement.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "tools/addon/phpmyadmin.sh",
|
||||
"resources": {
|
||||
"cpu": null,
|
||||
"ram": null,
|
||||
"hdd": null,
|
||||
"os": null,
|
||||
"version": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "Execute within an existing LXC Console",
|
||||
"type": "warning"
|
||||
},
|
||||
{
|
||||
"text": "To update or uninstall run bash call again",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox Backup Server Post Install",
|
||||
"name": "PBS Post Install",
|
||||
"slug": "post-pbs-install",
|
||||
"categories": [
|
||||
1
|
||||
@@ -13,7 +13,7 @@
|
||||
"website": null,
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
|
||||
"config_path": "",
|
||||
"description": "The script will give options to Disable the Enterprise Repo, Add/Correct PBS Sources, Enable the No-Subscription Repo, Add Test Repo, Disable Subscription Nag, Update Proxmox Backup Server and Reboot PBS.",
|
||||
"description": "The script is designed for Proxmox Backup Server (PBS) and will give options to Disable the Enterprise Repo, Add/Correct PBS Sources, Enable the No-Subscription Repo, Add Test Repo, Disable Subscription Nag, Update Proxmox Backup Server and Reboot PBS.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox Mail Gateway Post Install",
|
||||
"name": "PMG Post Install",
|
||||
"slug": "post-pmg-install",
|
||||
"categories": [
|
||||
1
|
||||
@@ -13,7 +13,7 @@
|
||||
"website": null,
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
|
||||
"config_path": "",
|
||||
"description": "The script will give options to Disable the Enterprise Repo, Add/Correct PMG Sources, Enable the No-Subscription Repo, Add Test Repo, Disable Subscription Nag, Update Proxmox Mail Gateway and Reboot PMG.",
|
||||
"description": "The script is designed for Proxmox Mail Gateway and will give options to Disable the Enterprise Repo, Add/Correct PMG Sources, Enable the No-Subscription Repo, Add Test Repo, Disable Subscription Nag, Update Proxmox Mail Gateway and Reboot PMG.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE Post Install",
|
||||
"name": "PVE Post Install",
|
||||
"slug": "post-pve-install",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
{
|
||||
"text": "Set a password after installation for postgres user by running `echo \"ALTER USER postgres with encrypted password 'your_password';\" | sudo -u postgres psql`",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"text": "Debian script offers versions `15, 16, 17, 18`, while Alpine script offers versions `15, 16, 17`.",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox Backup Server",
|
||||
"name": "Proxmox Backup Server (PBS)",
|
||||
"slug": "proxmox-backup-server",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox Datacenter Manager",
|
||||
"name": "Proxmox Datacenter Manager (PDM)",
|
||||
"slug": "proxmox-datacenter-manager",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox Mail Gateway",
|
||||
"name": "Proxmox Mail Gateway (PMG)",
|
||||
"slug": "proxmox-mail-gateway",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
35
scripts/json/pve-scripts-local.json
Normal file
35
scripts/json/pve-scripts-local.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "PVEScriptsLocal",
|
||||
"slug": "pve-scripts-local",
|
||||
"categories": [
|
||||
1
|
||||
],
|
||||
"date_created": "2025-10-03",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": 3000,
|
||||
"documentation": "https://github.com/community-scripts/ProxmoxVE-Local",
|
||||
"config_path": "/opt/PVEScripts-Local/.env",
|
||||
"website": "https://community-scripts.github.io/ProxmoxVE",
|
||||
"logo": "https://raw.githubusercontent.com/community-scripts/ProxmoxVE-Local/refs/heads/main/.github/logo.png",
|
||||
"description": "A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/pve-scripts-local.sh",
|
||||
"resources": {
|
||||
"cpu": 2,
|
||||
"ram": 4096,
|
||||
"hdd": 4,
|
||||
"os": "Debian",
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": []
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE CPU Scaling Governor",
|
||||
"name": "PVE CPU Scaling Governor",
|
||||
"slug": "scaling-governor",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"privileged": false,
|
||||
"interface_port": 3000,
|
||||
"documentation": "https://tracktor.bytedge.in/introduction.html",
|
||||
"config_path": "/opt/tracktor/app/server/.env",
|
||||
"config_path": "/opt/tracktor.env",
|
||||
"website": "https://tracktor.bytedge.in/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/tracktor.webp",
|
||||
"description": "Tracktor is an open-source web application for comprehensive vehicle management.\nEasily track fuel consumption, maintenance, insurance, and regulatory documents for all your vehicles in one place.",
|
||||
@@ -23,17 +23,17 @@
|
||||
"ram": 1024,
|
||||
"hdd": 6,
|
||||
"os": "Debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
"password": "123456"
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "Please check and update the '/opt/tracktor/app/backend/.env' file if using behind reverse proxy.",
|
||||
"text": "Please check and update the '/opt/tracktor.env' file if using behind reverse proxy.",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
|
||||
35
scripts/json/tunarr.json
Normal file
35
scripts/json/tunarr.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "Tunarr",
|
||||
"slug": "tunarr",
|
||||
"categories": [
|
||||
13
|
||||
],
|
||||
"date_created": "2025-09-19",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"config_path": "/opt/tunarr/.env",
|
||||
"interface_port": 8000,
|
||||
"documentation": "https://tunarr.com/",
|
||||
"website": "https://tunarr.com/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/tunarr.webp",
|
||||
"description": "Create a classic TV experience using your own media - IPTV backed by Plex/Jellyfin/Emby.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/tunarr.sh",
|
||||
"resources": {
|
||||
"cpu": 2,
|
||||
"ram": 1024,
|
||||
"hdd": 5,
|
||||
"os": "Debian",
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox VE LXC Updater",
|
||||
"name": "PVE LXC Updater",
|
||||
"slug": "update-lxcs",
|
||||
"categories": [
|
||||
1
|
||||
@@ -13,7 +13,7 @@
|
||||
"website": null,
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/linuxcontainers.webp",
|
||||
"config_path": "",
|
||||
"description": "This script has been created to simplify and speed up the process of updating all LXC containers across various Linux distributions, such as Ubuntu, Debian, Devuan, Alpine Linux, CentOS-Rocky-Alma, Fedora, and ArchLinux. It's designed to automatically skip templates and specific containers during the update, enhancing its convenience and usability.",
|
||||
"description": "This script has been created to simplify and speed up the process of updating the operating system running inside LXC containers across various Linux distributions, such as Ubuntu, Debian, Devuan, Alpine Linux, CentOS-Rocky-Alma, Fedora, and ArchLinux. It's designed to automatically skip templates and specific containers during the update, enhancing its convenience and usability.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
@@ -35,6 +35,10 @@
|
||||
{
|
||||
"text": "Execute within the Proxmox shell",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"text": "The script updates only the operating system of the LXC container. It DOES NOT update the application installed within the container!",
|
||||
"type": "warning"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Proxmox Update Repositories",
|
||||
"name": "PVE Update Repositories",
|
||||
"slug": "update-repo",
|
||||
"categories": [
|
||||
1
|
||||
|
||||
40
scripts/json/upsnap.json
Normal file
40
scripts/json/upsnap.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "UpSnap",
|
||||
"slug": "upsnap",
|
||||
"categories": [
|
||||
4
|
||||
],
|
||||
"date_created": "2025-09-23",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": 8090,
|
||||
"documentation": "https://github.com/seriousm4x/UpSnap/wiki",
|
||||
"config_path": "",
|
||||
"website": "https://github.com/seriousm4x/UpSnap",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/upsnap.webp",
|
||||
"description": "UpSnap is a self-hosted web app that lets you wake up, manage and monitor devices on your network with ease. Built with SvelteKit, Go and PocketBase, it offers a clean dashboard, scheduled wake-ups, device discovery and secure user management.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/upsnap.sh",
|
||||
"resources": {
|
||||
"cpu": 1,
|
||||
"ram": 512,
|
||||
"hdd": 2,
|
||||
"os": "Debian",
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "The first user you register will be the admin user.",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
scripts/json/verdaccio.json
Normal file
40
scripts/json/verdaccio.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "Verdaccio",
|
||||
"slug": "verdaccio",
|
||||
"categories": [
|
||||
20
|
||||
],
|
||||
"date_created": "2025-09-29",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": 4873,
|
||||
"documentation": "https://verdaccio.org/docs/what-is-verdaccio",
|
||||
"website": "https://verdaccio.org/",
|
||||
"logo": "https://verdaccio.org/img/logo/symbol/png/verdaccio-tiny.png",
|
||||
"config_path": "/opt/verdaccio/config/config.yaml",
|
||||
"description": "Verdaccio is a lightweight private npm proxy registry built with Node.js. It allows you to host your own npm registry with minimal configuration, providing a private npm repository for your projects. Verdaccio supports npm, yarn, and pnpm, and can cache packages from the public npm registry, allowing for faster installs and protection against npm registry outages. It includes a web interface for browsing packages, authentication and authorization features, and can be easily integrated into your development workflow.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/verdaccio.sh",
|
||||
"resources": {
|
||||
"cpu": 2,
|
||||
"ram": 2048,
|
||||
"hdd": 8,
|
||||
"os": "debian",
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "To create the first user, run: npm adduser --registry http://<container-ip>:4873",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
scripts/json/warracker.json
Normal file
40
scripts/json/warracker.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "Warracker",
|
||||
"slug": "warracker",
|
||||
"categories": [
|
||||
12
|
||||
],
|
||||
"date_created": "2025-09-29",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": 80,
|
||||
"documentation": null,
|
||||
"config_path": "/opt/.env",
|
||||
"website": "https://warracker.com/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/warracker.webp",
|
||||
"description": "Warracker is an open source, self-hostable warranty tracker to monitor expirations, store receipts, files. You own the data, your rules!",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/warracker.sh",
|
||||
"resources": {
|
||||
"cpu": 1,
|
||||
"ram": 512,
|
||||
"hdd": 4,
|
||||
"os": "Debian",
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "The first user you register will be the admin user.",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
"resources": {
|
||||
"cpu": 4,
|
||||
"ram": 4096,
|
||||
"hdd": 18,
|
||||
"hdd": 25,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 4096,
|
||||
"hdd": 6,
|
||||
"os": "debian",
|
||||
"version": "12"
|
||||
"version": "13"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -33,11 +33,19 @@
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "Database credentials: `cat zabbix.creds`",
|
||||
"text": "Database credentials: `cat ~/zabbix.creds`",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"text": "Zabbix agent 2 is used by default",
|
||||
"text": "You can choose between Zabbix agent (classic) and agent2 (modern) during installation",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"text": "For agent2 the PostgreSQL plugin is installed by default; all plugins are optional",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"text": "If agent2 with NVIDIA plugin is installed in an environment without GPU, the installer disables it automatically",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"documentation": "https://www.zigbee2mqtt.io/guide/getting-started/",
|
||||
"website": "https://www.zigbee2mqtt.io/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/zigbee2mqtt.webp",
|
||||
"config_path": "/opt/zigbee2mqtt/data/configuration.yaml",
|
||||
"config_path": "debian: /opt/zigbee2mqtt/data/configuration.yaml | alpine: /var/lib/zigbee2mqtt/configuration.yaml",
|
||||
"description": "Zigbee2MQTT is an open-source software project that allows you to use Zigbee-based smart home devices (such as those sold under the Philips Hue and Ikea Tradfri brands) with MQTT-based home automation systems, like Home Assistant, Node-RED, and others. The software acts as a bridge between your Zigbee devices and MQTT, allowing you to control and monitor these devices from your home automation system.",
|
||||
"install_methods": [
|
||||
{
|
||||
|
||||
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
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Mock the environment variables
|
||||
const mockEnv = {
|
||||
SCRIPTS_DIRECTORY: '/test/scripts',
|
||||
ALLOWED_SCRIPT_EXTENSIONS: '.sh,.py,.js,.ts',
|
||||
ALLOWED_SCRIPT_PATHS: '/,/ct/',
|
||||
MAX_SCRIPT_EXECUTION_TIME: '30000',
|
||||
REPO_URL: 'https://github.com/test/repo',
|
||||
NODE_ENV: 'test',
|
||||
}
|
||||
|
||||
vi.mock('~/env.js', () => ({
|
||||
env: mockEnv,
|
||||
}))
|
||||
|
||||
describe('Environment Configuration', () => {
|
||||
it('should have required environment variables', async () => {
|
||||
const { env } = await import('~/env.js')
|
||||
|
||||
expect(env.SCRIPTS_DIRECTORY).toBeDefined()
|
||||
expect(env.ALLOWED_SCRIPT_EXTENSIONS).toBeDefined()
|
||||
expect(env.ALLOWED_SCRIPT_PATHS).toBeDefined()
|
||||
expect(env.MAX_SCRIPT_EXECUTION_TIME).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have correct script directory path', async () => {
|
||||
const { env } = await import('~/env.js')
|
||||
|
||||
expect(env.SCRIPTS_DIRECTORY).toBe('/test/scripts')
|
||||
})
|
||||
|
||||
it('should have correct allowed extensions', async () => {
|
||||
const { env } = await import('~/env.js')
|
||||
|
||||
expect(env.ALLOWED_SCRIPT_EXTENSIONS).toBe('.sh,.py,.js,.ts')
|
||||
})
|
||||
|
||||
it('should have correct allowed paths', async () => {
|
||||
const { env } = await import('~/env.js')
|
||||
|
||||
expect(env.ALLOWED_SCRIPT_PATHS).toBe('/,/ct/')
|
||||
})
|
||||
|
||||
it('should have correct max execution time', async () => {
|
||||
const { env } = await import('~/env.js')
|
||||
|
||||
expect(env.MAX_SCRIPT_EXECUTION_TIME).toBe('30000')
|
||||
})
|
||||
})
|
||||
@@ -1,140 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import Home from '../page'
|
||||
|
||||
// Mock tRPC
|
||||
vi.mock('~/trpc/react', () => ({
|
||||
api: {
|
||||
scripts: {
|
||||
getRepoStatus: {
|
||||
useQuery: vi.fn(() => ({
|
||||
data: { isRepo: true, isBehind: false, branch: 'main', lastCommit: 'abc123' },
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
},
|
||||
getScriptCards: {
|
||||
useQuery: vi.fn(() => ({
|
||||
data: { success: true, cards: [] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
},
|
||||
getCtScripts: {
|
||||
useQuery: vi.fn(() => ({
|
||||
data: { scripts: [] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
},
|
||||
getScriptBySlug: {
|
||||
useQuery: vi.fn(() => ({
|
||||
data: null,
|
||||
})),
|
||||
},
|
||||
checkProxmoxVE: {
|
||||
useQuery: vi.fn(() => ({
|
||||
data: { success: true, isProxmoxVE: true },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
},
|
||||
fullUpdateRepo: {
|
||||
useMutation: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../_components/ScriptsGrid', () => ({
|
||||
ScriptsGrid: ({ onInstallScript }: { onInstallScript?: (path: string, name: string) => void }) => (
|
||||
<div data-testid="scripts-grid">
|
||||
<button onClick={() => onInstallScript?.('/test/path', 'test-script')}>
|
||||
Run Script
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../_components/ResyncButton', () => ({
|
||||
ResyncButton: () => <div data-testid="resync-button">Resync Button</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../_components/Terminal', () => ({
|
||||
Terminal: ({ scriptPath, onClose }: { scriptPath: string; onClose: () => void }) => (
|
||||
<div data-testid="terminal">
|
||||
<div>Terminal for: {scriptPath}</div>
|
||||
<button onClick={onClose}>Close Terminal</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Home Page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render main page elements', () => {
|
||||
render(<Home />)
|
||||
|
||||
expect(screen.getByText('🚀 PVE Scripts Management')).toBeInTheDocument()
|
||||
expect(screen.getByText('Manage and execute Proxmox helper scripts locally with live output streaming')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('resync-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('scripts-grid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show terminal initially', () => {
|
||||
render(<Home />)
|
||||
|
||||
expect(screen.queryByTestId('terminal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show terminal when script is run', () => {
|
||||
render(<Home />)
|
||||
|
||||
const runButton = screen.getByText('Run Script')
|
||||
fireEvent.click(runButton)
|
||||
|
||||
expect(screen.getByTestId('terminal')).toBeInTheDocument()
|
||||
expect(screen.getByText('Terminal for: /test/path')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close terminal when close button is clicked', () => {
|
||||
render(<Home />)
|
||||
|
||||
// First run a script to show terminal
|
||||
const runButton = screen.getByText('Run Script')
|
||||
fireEvent.click(runButton)
|
||||
|
||||
expect(screen.getByTestId('terminal')).toBeInTheDocument()
|
||||
|
||||
// Then close the terminal
|
||||
const closeButton = screen.getByText('Close Terminal')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(screen.queryByTestId('terminal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple script runs', () => {
|
||||
render(<Home />)
|
||||
|
||||
// Run first script
|
||||
const runButton = screen.getByText('Run Script')
|
||||
fireEvent.click(runButton)
|
||||
|
||||
expect(screen.getByText('Terminal for: /test/path')).toBeInTheDocument()
|
||||
|
||||
// Close terminal
|
||||
const closeButton = screen.getByText('Close Terminal')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(screen.queryByTestId('terminal')).not.toBeInTheDocument()
|
||||
|
||||
// Run second script
|
||||
fireEvent.click(runButton)
|
||||
|
||||
expect(screen.getByText('Terminal for: /test/path')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
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;
|
||||
}
|
||||
140
src/app/_components/Badge.tsx
Normal file
140
src/app/_components/Badge.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
variant: 'type' | 'updateable' | 'privileged' | 'status' | 'note' | 'execution-mode';
|
||||
type?: string;
|
||||
noteType?: 'info' | 'warning' | 'error';
|
||||
status?: 'success' | 'failed' | 'in_progress';
|
||||
executionMode?: 'local' | 'ssh';
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Badge({ variant, type, noteType, status, executionMode, children, className = '' }: BadgeProps) {
|
||||
const getTypeStyles = (scriptType: string) => {
|
||||
switch (scriptType.toLowerCase()) {
|
||||
case 'ct':
|
||||
return 'bg-primary/10 text-primary border-primary/20';
|
||||
case 'addon':
|
||||
return 'bg-purple-500/10 text-purple-400 border-purple-500/20';
|
||||
case 'vm':
|
||||
return 'bg-green-500/10 text-green-400 border-green-500/20';
|
||||
case 'pve':
|
||||
return 'bg-orange-500/10 text-orange-400 border-orange-500/20';
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground border-border';
|
||||
}
|
||||
};
|
||||
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'type':
|
||||
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles('unknown')}`;
|
||||
|
||||
case 'updateable':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20';
|
||||
|
||||
case 'privileged':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
|
||||
|
||||
case 'status':
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20';
|
||||
case 'failed':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
|
||||
case 'in_progress':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20';
|
||||
default:
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
|
||||
}
|
||||
|
||||
case 'execution-mode':
|
||||
switch (executionMode) {
|
||||
case 'local':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
|
||||
case 'ssh':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20';
|
||||
default:
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
|
||||
}
|
||||
|
||||
case 'note':
|
||||
switch (noteType) {
|
||||
case 'warning':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20';
|
||||
case 'error':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
|
||||
default:
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
|
||||
}
|
||||
|
||||
default:
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
|
||||
}
|
||||
};
|
||||
|
||||
// Format the text for type badges
|
||||
const formatText = () => {
|
||||
if (variant === 'type' && type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'ct':
|
||||
return 'LXC';
|
||||
case 'addon':
|
||||
return 'ADDON';
|
||||
case 'vm':
|
||||
return 'VM';
|
||||
case 'pve':
|
||||
return 'PVE';
|
||||
default:
|
||||
return type.toUpperCase();
|
||||
}
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`${getVariantStyles()} ${className}`}>
|
||||
{formatText()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Convenience components for common use cases
|
||||
export const TypeBadge = ({ type, className }: { type: string; className?: string }) => (
|
||||
<Badge variant="type" type={type} className={className}>
|
||||
{type}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const UpdateableBadge = ({ className }: { className?: string }) => (
|
||||
<Badge variant="updateable" className={className}>
|
||||
Updateable
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const PrivilegedBadge = ({ className }: { className?: string }) => (
|
||||
<Badge variant="privileged" className={className}>
|
||||
Privileged
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const StatusBadge = ({ status, children, className }: { status: 'success' | 'failed' | 'in_progress'; children: React.ReactNode; className?: string }) => (
|
||||
<Badge variant="status" status={status} className={className}>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const ExecutionModeBadge = ({ mode, children, className }: { mode: 'local' | 'ssh'; children: React.ReactNode; className?: string }) => (
|
||||
<Badge variant="execution-mode" executionMode={mode} className={className}>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const NoteBadge = ({ noteType, children, className }: { noteType: 'info' | 'warning' | 'error'; children: React.ReactNode; className?: string }) => (
|
||||
<Badge variant="note" noteType={noteType} className={className}>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
367
src/app/_components/CategorySidebar.tsx
Normal file
367
src/app/_components/CategorySidebar.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
|
||||
interface CategorySidebarProps {
|
||||
categories: string[];
|
||||
categoryCounts: Record<string, number>;
|
||||
totalScripts: number;
|
||||
selectedCategory: string | null;
|
||||
onCategorySelect: (category: string | null) => void;
|
||||
}
|
||||
|
||||
// Icon mapping for categories
|
||||
const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; className?: string }) => {
|
||||
const iconMap: Record<string, React.ReactElement> = {
|
||||
server: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
monitor: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
box: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
),
|
||||
shield: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
"shield-check": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
key: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z" />
|
||||
</svg>
|
||||
),
|
||||
archive: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
),
|
||||
database: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
),
|
||||
"chart-bar": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
template: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
),
|
||||
"folder-open": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
"document-text": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
film: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m0 0V1.5a.5.5 0 01.5-.5h1a.5.5 0 01.5.5V4m-3 0H9m3 0v16a1 1 0 01-1 1H8a1 1 0 01-1-1V4m6 0h2a2 2 0 012 2v12a2 2 0 01-2 2h-2V4z" />
|
||||
</svg>
|
||||
),
|
||||
download: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
"video-camera": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
home: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
wifi: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
),
|
||||
"chat-alt": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
),
|
||||
clock: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
code: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
"external-link": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
),
|
||||
sparkles: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
),
|
||||
"currency-dollar": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
</svg>
|
||||
),
|
||||
puzzle: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
),
|
||||
office: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return iconMap[iconName] ?? (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export function CategorySidebar({
|
||||
categories,
|
||||
categoryCounts,
|
||||
totalScripts,
|
||||
selectedCategory,
|
||||
onCategorySelect
|
||||
}: CategorySidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
// Category to icon mapping (based on metadata.json)
|
||||
const categoryIconMapping: Record<string, string> = {
|
||||
'Proxmox & Virtualization': 'server',
|
||||
'Operating Systems': 'monitor',
|
||||
'Containers & Docker': 'box',
|
||||
'Network & Firewall': 'shield',
|
||||
'Adblock & DNS': 'shield-check',
|
||||
'Authentication & Security': 'key',
|
||||
'Backup & Recovery': 'archive',
|
||||
'Databases': 'database',
|
||||
'Monitoring & Analytics': 'chart-bar',
|
||||
'Dashboards & Frontends': 'template',
|
||||
'Files & Downloads': 'folder-open',
|
||||
'Documents & Notes': 'document-text',
|
||||
'Media & Streaming': 'film',
|
||||
'*Arr Suite': 'download',
|
||||
'NVR & Cameras': 'video-camera',
|
||||
'IoT & Smart Home': 'home',
|
||||
'ZigBee, Z-Wave & Matter': 'wifi',
|
||||
'MQTT & Messaging': 'chat-alt',
|
||||
'Automation & Scheduling': 'clock',
|
||||
'AI / Coding & Dev-Tools': 'code',
|
||||
'Webservers & Proxies': 'external-link',
|
||||
'Bots & ChatOps': 'sparkles',
|
||||
'Finance & Budgeting': 'currency-dollar',
|
||||
'Gaming & Leisure': 'puzzle',
|
||||
'Business & ERP': 'office',
|
||||
'Miscellaneous': 'box'
|
||||
};
|
||||
|
||||
// Sort categories by count (descending) and then alphabetically
|
||||
const sortedCategories = categories
|
||||
.map(category => [category, categoryCounts[category] ?? 0] as const)
|
||||
.sort(([a, countA], [b, countB]) => {
|
||||
if (countB !== countA) return countB - countA;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
|
||||
isCollapsed ? 'w-16' : 'w-full lg:w-80'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
|
||||
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
|
||||
</div>
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with categories" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
title={isCollapsed ? 'Expand categories' : 'Collapse categories'}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 text-muted-foreground transition-transform ${
|
||||
isCollapsed ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded state - show full categories */}
|
||||
{!isCollapsed && (
|
||||
<div className="p-4">
|
||||
<div className="space-y-2">
|
||||
{/* "All Categories" option */}
|
||||
<button
|
||||
onClick={() => onCategorySelect(null)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
|
||||
selectedCategory === null
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'hover:bg-accent text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<CategoryIcon
|
||||
iconName="template"
|
||||
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<span className="font-medium">All Categories</span>
|
||||
</div>
|
||||
<span className={`text-sm px-2 py-1 rounded-full ${
|
||||
selectedCategory === null
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{totalScripts}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Individual Categories */}
|
||||
{sortedCategories.map(([category, count]) => {
|
||||
const isSelected = selectedCategory === category;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onCategorySelect(category)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'hover:bg-accent text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<CategoryIcon
|
||||
iconName={categoryIconMapping[category] ?? 'box'}
|
||||
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<span className="font-medium capitalize">
|
||||
{category.replace(/[_-]/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-sm px-2 py-1 rounded-full ${
|
||||
isSelected
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed state - show only icons with counters and tooltips */}
|
||||
{isCollapsed && (
|
||||
<div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
|
||||
{/* "All Categories" option */}
|
||||
<div className="group relative">
|
||||
<button
|
||||
onClick={() => onCategorySelect(null)}
|
||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
|
||||
selectedCategory === null
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'hover:bg-accent text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<CategoryIcon
|
||||
iconName="template"
|
||||
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
<span className={`text-xs mt-1 px-1 rounded ${
|
||||
selectedCategory === null
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{totalScripts}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
|
||||
All Categories ({totalScripts})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Individual Categories */}
|
||||
{sortedCategories.map(([category, count]) => {
|
||||
const isSelected = selectedCategory === category;
|
||||
|
||||
return (
|
||||
<div key={category} className="group relative">
|
||||
<button
|
||||
onClick={() => onCategorySelect(category)}
|
||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'hover:bg-accent text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<CategoryIcon
|
||||
iconName={categoryIconMapping[category] ?? 'box'}
|
||||
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
<span className={`text-xs mt-1 px-1 rounded ${
|
||||
isSelected
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
|
||||
{category} ({count})
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
46
src/app/_components/ContextualHelpIcon.tsx
Normal file
46
src/app/_components/ContextualHelpIcon.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { HelpModal } from './HelpModal';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
|
||||
interface ContextualHelpIconProps {
|
||||
section: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'default';
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function ContextualHelpIcon({
|
||||
section,
|
||||
className = '',
|
||||
size = 'sm',
|
||||
tooltip = 'Help'
|
||||
}: ContextualHelpIconProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const sizeClasses = size === 'sm'
|
||||
? 'h-7 w-7 p-1.5'
|
||||
: 'h-9 w-9 p-2';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted ${className}`}
|
||||
title={tooltip}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<HelpModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
initialSection={section}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -45,17 +45,17 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
key={index}
|
||||
className={`flex font-mono text-sm ${
|
||||
isAdded
|
||||
? 'bg-green-50 text-green-800 border-l-4 border-green-400'
|
||||
? 'bg-green-500/10 text-green-400 border-l-4 border-green-500'
|
||||
: isRemoved
|
||||
? 'bg-red-50 text-red-800 border-l-4 border-red-400'
|
||||
: 'bg-gray-50 text-gray-700'
|
||||
? 'bg-destructive/10 text-destructive border-l-4 border-destructive'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 text-right pr-2 text-gray-500 select-none">
|
||||
<div className="w-16 text-right pr-2 text-muted-foreground select-none">
|
||||
{lineNumber}
|
||||
</div>
|
||||
<div className="flex-1 pl-2">
|
||||
<span className={isAdded ? 'text-green-600' : isRemoved ? 'text-red-600' : ''}>
|
||||
<span className={isAdded ? 'text-green-400' : isRemoved ? 'text-destructive' : ''}>
|
||||
{isAdded ? '+' : isRemoved ? '-' : ' '}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap">{content}</span>
|
||||
@@ -66,27 +66,27 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden border border-border mx-4 sm:mx-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Script Diff</h2>
|
||||
<p className="text-sm text-gray-600">{filePath}</p>
|
||||
<h2 className="text-xl font-bold text-foreground">Script Diff</h2>
|
||||
<p className="text-sm text-muted-foreground">{filePath}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors disabled:opacity-50"
|
||||
className="px-3 py-1 text-sm bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -96,19 +96,19 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="px-4 py-2 bg-gray-50 border-b border-gray-200">
|
||||
<div className="px-4 py-2 bg-muted border-b border-border">
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-green-100 border border-green-300"></div>
|
||||
<span className="text-green-700">Added (Remote)</span>
|
||||
<div className="w-3 h-3 bg-green-500/20 border border-green-500/40"></div>
|
||||
<span className="text-green-400">Added (Remote)</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-red-100 border border-red-300"></div>
|
||||
<span className="text-red-700">Removed (Local)</span>
|
||||
<div className="w-3 h-3 bg-destructive/20 border border-destructive/40"></div>
|
||||
<span className="text-destructive">Removed (Local)</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-gray-100 border border-gray-300"></div>
|
||||
<span className="text-gray-700">Unchanged</span>
|
||||
<div className="w-3 h-3 bg-muted border border-border"></div>
|
||||
<span className="text-muted-foreground">Unchanged</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,14 +117,14 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
{diffData?.success ? (
|
||||
diffData.diff ? (
|
||||
<div className="divide-y divide-gray-200">
|
||||
<div className="divide-y divide-border">
|
||||
{diffData.diff.split('\n').map((line, index) =>
|
||||
line.trim() ? renderDiffLine(line, index) : null
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>No differences found</p>
|
||||
@@ -132,16 +132,16 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
</div>
|
||||
)
|
||||
) : diffData?.error ? (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="p-8 text-center text-destructive">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>Error loading diff</p>
|
||||
<p className="text-sm">{diffData.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p>Loading diff...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
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 || downloadedScripts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-muted-foreground">
|
||||
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium">No downloaded scripts found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You haven'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 type { Server } from '../../types/server';
|
||||
import { Button } from './ui/button';
|
||||
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
|
||||
|
||||
interface ExecutionModeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,8 +18,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local');
|
||||
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -23,6 +27,20 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-select server when exactly one server is available
|
||||
useEffect(() => {
|
||||
if (isOpen && !loading && servers.length === 1) {
|
||||
setSelectedServer(servers[0] ?? null);
|
||||
}
|
||||
}, [isOpen, loading, servers]);
|
||||
|
||||
// Refresh servers when settings modal closes
|
||||
const handleSettingsModalClose = () => {
|
||||
setSettingsModalOpen(false);
|
||||
// Refetch servers to reflect any changes made in settings
|
||||
void fetchServers();
|
||||
};
|
||||
|
||||
const fetchServers = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -32,7 +50,11 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
throw new Error('Failed to fetch servers');
|
||||
}
|
||||
const data = await response.json();
|
||||
setServers(data as Server[]);
|
||||
// Sort servers by name alphabetically
|
||||
const sortedServers = (data as Server[]).sort((a, b) =>
|
||||
(a.name ?? '').localeCompare(b.name ?? '')
|
||||
);
|
||||
setServers(sortedServers);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
@@ -41,166 +63,175 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
if (selectedMode === 'ssh' && !selectedServer) {
|
||||
if (!selectedServer) {
|
||||
setError('Please select a server for SSH execution');
|
||||
return;
|
||||
}
|
||||
|
||||
onExecute(selectedMode, selectedServer ?? undefined);
|
||||
onExecute('ssh', selectedServer);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleModeChange = (mode: 'local' | 'ssh') => {
|
||||
setSelectedMode(mode);
|
||||
if (mode === 'local') {
|
||||
setSelectedServer(null);
|
||||
}
|
||||
|
||||
const handleServerSelect = (server: Server | null) => {
|
||||
setSelectedServer(server);
|
||||
};
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">Execution Mode</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Where would you like to execute "{scriptName}"?
|
||||
</h3>
|
||||
|
||||
<>
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-bold text-foreground">Select Server</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">No servers configured</p>
|
||||
<p className="text-xs mt-1">Add servers in Settings to execute scripts</p>
|
||||
<Button
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
>
|
||||
Open Server Settings
|
||||
</Button>
|
||||
</div>
|
||||
) : servers.length === 1 ? (
|
||||
/* Single Server Confirmation View */
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
Install Script Confirmation
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Do you want to install "{scriptName}" on the following server?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-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 className="w-3 h-3 bg-green-500 rounded-full"></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 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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
variant="default"
|
||||
size="default"
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Selection (only for SSH mode) */}
|
||||
{selectedMode === 'ssh' && (
|
||||
<div className="mb-6">
|
||||
<label htmlFor="server" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Server
|
||||
</label>
|
||||
{loading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-2 text-sm text-gray-600">Loading servers...</p>
|
||||
) : (
|
||||
/* 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>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<p className="text-sm">No servers configured</p>
|
||||
<p className="text-xs mt-1">Add servers in Settings to use SSH execution</p>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="server"
|
||||
value={selectedServer?.id ?? ''}
|
||||
onChange={(e) => {
|
||||
const serverId = parseInt(e.target.value);
|
||||
const server = servers.find(s => s.id === serverId);
|
||||
setSelectedServer(server ?? null);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Select a server...</option>
|
||||
{servers.map((server) => (
|
||||
<option key={server.id} value={server.id}>
|
||||
{server.name} ({server.ip}) - {server.user}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={selectedMode === 'ssh' && !selectedServer}
|
||||
className={`px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
|
||||
selectedMode === 'ssh' && !selectedServer
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'}
|
||||
</button>
|
||||
{/* Server Selection */}
|
||||
<div className="mb-6">
|
||||
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
|
||||
Select Server
|
||||
</label>
|
||||
<ColorCodedDropdown
|
||||
servers={servers}
|
||||
selectedServer={selectedServer}
|
||||
onServerSelect={handleServerSelect}
|
||||
placeholder="Select a server..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
disabled={!selectedServer}
|
||||
variant="default"
|
||||
size="default"
|
||||
className={!selectedServer ? 'bg-gray-400 cursor-not-allowed' : ''}
|
||||
>
|
||||
Run on Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Settings Modal */}
|
||||
<SettingsModal
|
||||
isOpen={settingsModalOpen}
|
||||
onClose={handleSettingsModalClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
446
src/app/_components/FilterBar.tsx
Normal file
446
src/app/_components/FilterBar.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
|
||||
|
||||
export interface FilterState {
|
||||
searchQuery: string;
|
||||
showUpdatable: boolean | null; // null = all, true = only updatable, false = only non-updatable
|
||||
selectedTypes: string[]; // Array of selected types: 'lxc', 'vm', 'addon', 'pve'
|
||||
sortBy: "name" | "created"; // Sort criteria (removed 'updated')
|
||||
sortOrder: "asc" | "desc"; // Sort direction
|
||||
}
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: FilterState;
|
||||
onFiltersChange: (filters: FilterState) => void;
|
||||
totalScripts: number;
|
||||
filteredCount: number;
|
||||
updatableCount?: number;
|
||||
saveFiltersEnabled?: boolean;
|
||||
isLoadingFilters?: boolean;
|
||||
}
|
||||
|
||||
const SCRIPT_TYPES = [
|
||||
{ value: "ct", label: "LXC Container", Icon: Package },
|
||||
{ value: "vm", label: "Virtual Machine", Icon: Monitor },
|
||||
{ value: "addon", label: "Add-on", Icon: Wrench },
|
||||
{ value: "pve", label: "PVE Host", Icon: Server },
|
||||
];
|
||||
|
||||
export function FilterBar({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
totalScripts,
|
||||
filteredCount,
|
||||
updatableCount = 0,
|
||||
saveFiltersEnabled = false,
|
||||
isLoadingFilters = false,
|
||||
}: FilterBarProps) {
|
||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||
|
||||
const updateFilters = (updates: Partial<FilterState>) => {
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
onFiltersChange({
|
||||
searchQuery: "",
|
||||
showUpdatable: null,
|
||||
selectedTypes: [],
|
||||
sortBy: "name",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.searchQuery ||
|
||||
filters.showUpdatable !== null ||
|
||||
filters.selectedTypes.length > 0 ||
|
||||
filters.sortBy !== "name" ||
|
||||
filters.sortOrder !== "asc";
|
||||
|
||||
const getUpdatableButtonText = () => {
|
||||
if (filters.showUpdatable === null) return "Updatable: All";
|
||||
if (filters.showUpdatable === true)
|
||||
return `Updatable: Yes (${updatableCount})`;
|
||||
return "Updatable: No";
|
||||
};
|
||||
|
||||
const getTypeButtonText = () => {
|
||||
if (filters.selectedTypes.length === 0) return "All Types";
|
||||
if (filters.selectedTypes.length === 1) {
|
||||
const type = SCRIPT_TYPES.find(
|
||||
(t) => t.value === filters.selectedTypes[0],
|
||||
);
|
||||
return type?.label ?? filters.selectedTypes[0];
|
||||
}
|
||||
return `${filters.selectedTypes.length} Types`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
||||
{/* 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 Persistence Status */}
|
||||
{!isLoadingFilters && saveFiltersEnabled && (
|
||||
<div className="mb-4 flex items-center justify-center py-1">
|
||||
<div className="flex items-center space-x-2 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>
|
||||
)}
|
||||
|
||||
{/* Filter Header */}
|
||||
{!isLoadingFilters && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative max-w-md w-full">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search scripts..."
|
||||
value={filters.searchQuery}
|
||||
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
|
||||
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
|
||||
/>
|
||||
{filters.searchQuery && (
|
||||
<Button
|
||||
onClick={() => updateFilters({ searchQuery: "" })}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
||||
{/* Updateable Filter */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next =
|
||||
filters.showUpdatable === null
|
||||
? true
|
||||
: filters.showUpdatable === true
|
||||
? false
|
||||
: null;
|
||||
updateFilters({ showUpdatable: next });
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||
filters.showUpdatable === null
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: filters.showUpdatable === true
|
||||
? "border border-green-500/20 bg-green-500/10 text-green-400"
|
||||
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>{getUpdatableButtonText()}</span>
|
||||
</Button>
|
||||
|
||||
{/* Type Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full flex items-center justify-center space-x-2 ${
|
||||
filters.selectedTypes.length === 0
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: "border border-primary/20 bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{getTypeButtonText()}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{isTypeDropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
|
||||
<div className="p-2">
|
||||
{SCRIPT_TYPES.map((type) => {
|
||||
const IconComponent = type.Icon;
|
||||
return (
|
||||
<label
|
||||
key={type.value}
|
||||
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.selectedTypes.includes(type.value)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
updateFilters({
|
||||
selectedTypes: [
|
||||
...filters.selectedTypes,
|
||||
type.value,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
updateFilters({
|
||||
selectedTypes: filters.selectedTypes.filter(
|
||||
(t) => t !== type.value,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="rounded border-input text-primary focus:ring-primary"
|
||||
/>
|
||||
<IconComponent className="h-4 w-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{type.label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-border p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateFilters({ selectedTypes: [] });
|
||||
setIsTypeDropdownOpen(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort By Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{filters.sortBy === "name" ? (
|
||||
<FileText className="h-4 w-4" />
|
||||
) : (
|
||||
<Calendar className="h-4 w-4" />
|
||||
)}
|
||||
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{isSortDropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "name" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">By Name</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "created" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">By Created Date</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Order Button */}
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateFilters({
|
||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{filters.sortOrder === "asc" ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 11l5-5m0 0l5 5m-5-5v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 13l-5 5m0 0l-5-5m5 5V6"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Summary and Clear All */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{filteredCount === totalScripts ? (
|
||||
<span>Showing all {totalScripts} scripts</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredCount} of {totalScripts} scripts{" "}
|
||||
{hasActiveFilters && (
|
||||
<span className="font-medium text-blue-600">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800 w-full sm:w-auto justify-center sm:justify-start"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Clear all filters</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Click outside to close dropdowns */}
|
||||
{(isTypeDropdownOpen || isSortDropdownOpen) && (
|
||||
<div
|
||||
className="fixed inset-0 z-0"
|
||||
onClick={() => {
|
||||
setIsTypeDropdownOpen(false);
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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-6 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>© 2024 PVE Scripts Local</span>
|
||||
{versionData?.success && versionData.version && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenReleaseNotes}
|
||||
className="h-auto p-1 text-xs hover:text-foreground"
|
||||
>
|
||||
v{versionData.version}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenReleaseNotes}
|
||||
className="h-auto p-2 text-xs hover:text-foreground"
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Release Notes
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="h-auto p-2 text-xs hover:text-foreground"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/community-scripts/ProxmoxVE-Local"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
515
src/app/_components/HelpModal.tsx
Normal file
515
src/app/_components/HelpModal.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle, Server, Settings, RefreshCw, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
|
||||
|
||||
interface HelpModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialSection?: string;
|
||||
}
|
||||
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'update-system';
|
||||
|
||||
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sections = [
|
||||
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
|
||||
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings },
|
||||
{ id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
|
||||
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
|
||||
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
|
||||
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
|
||||
{ id: 'update-system' as HelpSection, label: 'Update System', icon: Download },
|
||||
];
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeSection) {
|
||||
case 'server-settings':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">Server Settings</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Manage your Proxmox VE servers and configure connection settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Adding PVE Servers</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Server Name:</strong> A friendly name to identify your server</li>
|
||||
<li>• <strong>IP Address:</strong> The IP address or hostname of your PVE server</li>
|
||||
<li>• <strong>Username:</strong> PVE user account (usually root or a dedicated user)</li>
|
||||
<li>• <strong>SSH Port:</strong> Default is 22, change if your server uses a different port</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Authentication Types</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Password:</strong> Use username and password authentication</li>
|
||||
<li>• <strong>SSH Key:</strong> Use SSH key pair for secure authentication</li>
|
||||
<li>• <strong>Both:</strong> Try SSH key first, fallback to password if needed</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Assign colors to servers for visual distinction throughout the application.
|
||||
This helps identify which server you'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>Execution Logs:</strong> View output and logs from script installations</li>
|
||||
<li>• <strong>Filtering:</strong> Filter by server, status, or search terms</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Managing Installed Scripts</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>View All Scripts:</strong> See all tracked scripts across all servers</li>
|
||||
<li>• <strong>Filter by Server:</strong> Show scripts for a specific PVE server</li>
|
||||
<li>• <strong>Filter by Status:</strong> Show successful, failed, or in-progress installations</li>
|
||||
<li>• <strong>Sort Options:</strong> Sort by name, container ID, server, status, or date</li>
|
||||
<li>• <strong>Update Scripts:</strong> Re-run or update existing script installations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
|
||||
<h4 className="font-medium text-foreground mb-2">Container Control (NEW)</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Directly control LXC containers from the installed scripts page via SSH.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Start/Stop Button:</strong> Control container state with <code>pct start/stop <ID></code></li>
|
||||
<li>• <strong>Container Status:</strong> Real-time status indicator (running/stopped/unknown)</li>
|
||||
<li>• <strong>Destroy Button:</strong> Permanently remove LXC container with <code>pct destroy <ID></code></li>
|
||||
<li>• <strong>Confirmation Modals:</strong> Simple OK/Cancel for start/stop, type container ID to confirm destroy</li>
|
||||
<li>• <strong>SSH Execution:</strong> All commands executed remotely via configured SSH connections</li>
|
||||
</ul>
|
||||
<div className="mt-3 p-3 bg-muted/30 dark:bg-muted/20 rounded-lg border border-border">
|
||||
<p className="text-sm font-medium text-foreground">⚠️ Safety Features:</p>
|
||||
<ul className="text-sm text-muted-foreground mt-1 space-y-1">
|
||||
<li>• Start/Stop actions require simple confirmation</li>
|
||||
<li>• Destroy action requires typing the container ID to confirm</li>
|
||||
<li>• All actions show loading states and error handling</li>
|
||||
<li>• Only works with SSH scripts that have valid container IDs</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'update-system':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">Update System</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Keep your PVE Scripts Management application up to date with the latest features and improvements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">What Does Updating Do?</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Downloads Latest Version:</strong> Fetches the newest release from the GitHub repository</li>
|
||||
<li>• <strong>Updates Application Files:</strong> Replaces current files with the latest version</li>
|
||||
<li>• <strong>Installs Dependencies:</strong> Updates Node.js packages and dependencies</li>
|
||||
<li>• <strong>Rebuilds Application:</strong> Compiles the application with latest changes</li>
|
||||
<li>• <strong>Restarts Server:</strong> Automatically restarts the application server</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">How to Update</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h5 className="font-medium text-foreground mb-2">Automatic Update (Recommended)</h5>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Click the "Update Now" button when an update is available</li>
|
||||
<li>• The system will handle everything automatically</li>
|
||||
<li>• You'll see a progress overlay with update logs</li>
|
||||
<li>• The page will reload automatically when complete</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="font-medium text-foreground mb-2">Manual Update (Advanced)</h5>
|
||||
<p className="text-sm text-muted-foreground mb-2">If automatic update fails, you can update manually:</p>
|
||||
<div className="bg-muted p-3 rounded-lg font-mono text-sm">
|
||||
<div className="text-muted-foreground"># Navigate to the application directory</div>
|
||||
<div>cd $PVESCRIPTLOCAL_DIR</div>
|
||||
<div className="text-muted-foreground"># Pull latest changes</div>
|
||||
<div>git pull</div>
|
||||
<div className="text-muted-foreground"># Install dependencies</div>
|
||||
<div>npm install</div>
|
||||
<div className="text-muted-foreground"># Build the application</div>
|
||||
<div>npm run build</div>
|
||||
<div className="text-muted-foreground"># Start the application</div>
|
||||
<div>npm start</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Update Process</h4>
|
||||
<ol className="text-sm text-muted-foreground space-y-2">
|
||||
<li><strong>1. Check for Updates:</strong> System automatically checks GitHub for new releases</li>
|
||||
<li><strong>2. Download Update:</strong> Downloads the latest release files</li>
|
||||
<li><strong>3. Backup Current Version:</strong> Creates backup of current installation</li>
|
||||
<li><strong>4. Install New Version:</strong> Replaces files and updates dependencies</li>
|
||||
<li><strong>5. Build Application:</strong> Compiles the updated code</li>
|
||||
<li><strong>6. Restart Server:</strong> Stops old server and starts new version</li>
|
||||
<li><strong>7. Reload Page:</strong> Automatically refreshes the browser</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Release Notes</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Click the external link icon next to the update button to view detailed release notes on GitHub.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• See what's new in each version</li>
|
||||
<li>• Read about bug fixes and improvements</li>
|
||||
<li>• Check for any breaking changes</li>
|
||||
<li>• View installation requirements</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-muted/50">
|
||||
<h4 className="font-medium text-foreground mb-2">Important Notes</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Backup:</strong> Your data and settings are preserved during updates</li>
|
||||
<li>• <strong>Downtime:</strong> Brief downtime occurs during the update process</li>
|
||||
<li>• <strong>Compatibility:</strong> Updates maintain backward compatibility with your data</li>
|
||||
<li>• <strong>Rollback:</strong> If issues occur, you can manually revert to previous version</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground flex items-center gap-2">
|
||||
<HelpCircle className="w-6 h-6" />
|
||||
Help & Documentation
|
||||
</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(95vh-120px)] sm:h-[calc(90vh-140px)]">
|
||||
{/* Sidebar Navigation */}
|
||||
<div className="w-64 border-r border-border bg-muted/30 overflow-y-auto">
|
||||
<nav className="p-4 space-y-2">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
variant={activeSection === section.id ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-left"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{section.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 sm:p-6">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user