Compare commits

..

33 Commits

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

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

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Workflow

* Workflow

* Workflow

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update.sh

* Update update.sh

* Update update.sh

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: bump dependencies to latest versions

---------

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

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

* fix: Resolve ESLint errors in DownloadedScriptsTab

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

View File

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

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

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

View File

@@ -2,6 +2,11 @@
A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming. No more need for curl -> bash calls, it all happens in your enviroment.
<img width="1725" height="1088" alt="image" src="https://github.com/user-attachments/assets/75323765-7375-4346-a41e-08d219275248" />
## 🎯 Deployment Options
This application can be deployed in multiple ways to suit different environments:

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.2.1

1444
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,17 +22,21 @@
"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",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"next": "^15.5.3",
"node-pty": "^1.0.0",
"react": "^19.0.0",
@@ -42,13 +46,14 @@
"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"
},
"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/better-sqlite3": "^7.6.8",
@@ -59,12 +64,12 @@
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"eslint-config-next": "^15.5.4",
"jsdom": "^26.1.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"

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(dirname "$0")"
source "$SCRIPT_DIR/../core/build.func"
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
APP="Debian"
var_tags="${var_tags:-os}"
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-512}"
var_disk="${var_disk:-2}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /var ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
msg_info "Updating $APP LXC"
$STD apt update
$STD apt -y upgrade
msg_ok "Updated $APP LXC"
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
motd_ssh
customize
msg_info "Cleaning up"
$STD apt -y autoremove
$STD apt -y autoclean
$STD apt -y clean
msg_ok "Cleaned"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
"use client";
import React, { useState } from "react";
import { Button } from "./ui/button";
import { Package, Monitor, Wrench, Server, FileText, Calendar } from "lucide-react";
export interface FilterState {
searchQuery: string;
@@ -19,10 +21,10 @@ interface FilterBarProps {
}
const SCRIPT_TYPES = [
{ value: "ct", label: "LXC Container", icon: "📦" },
{ value: "vm", label: "Virtual Machine", icon: "💻" },
{ value: "addon", label: "Add-on", icon: "🔧" },
{ value: "pve", label: "PVE Host", icon: "🖥️" },
{ value: "ct", label: "LXC Container", Icon: Package },
{ value: "vm", label: "Virtual Machine", Icon: Monitor },
{ value: "addon", label: "Add-on", Icon: Wrench },
{ value: "pve", label: "PVE Host", Icon: Server },
];
export function FilterBar({
@@ -74,13 +76,13 @@ export function FilterBar({
};
return (
<div className="mb-6 rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="mb-6 rounded-lg border border-border bg-card p-6 shadow-sm">
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-gray-400 dark:text-gray-500"
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -98,12 +100,14 @@ export function FilterBar({
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-gray-300 bg-white py-3 pr-10 pl-10 text-sm leading-5 text-gray-900 placeholder-gray-500 focus:border-blue-500 focus:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:placeholder-gray-300 dark:focus:ring-blue-400"
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/>
{filters.searchQuery && (
<button
<Button
onClick={() => updateFilters({ searchQuery: "" })}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
variant="ghost"
size="icon"
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
>
<svg
className="h-5 w-5"
@@ -118,7 +122,7 @@ export function FilterBar({
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Button>
)}
</div>
</div>
@@ -126,7 +130,7 @@ export function FilterBar({
{/* Filter Buttons */}
<div className="mb-4 flex flex-wrap gap-3">
{/* Updateable Filter */}
<button
<Button
onClick={() => {
const next =
filters.showUpdatable === null
@@ -136,25 +140,29 @@ export function FilterBar({
: null;
updateFilters({ showUpdatable: next });
}}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
filters.showUpdatable === null
? "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
: filters.showUpdatable === true
? "border border-green-300 bg-green-100 text-green-800 dark:border-green-700 dark:bg-green-900/50 dark:text-green-200"
: "border border-red-300 bg-red-100 text-red-800 dark:border-red-700 dark:bg-red-900/50 dark:text-red-200"
}`}
variant="outline"
size="default"
className={`${
filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent"
: filters.showUpdatable === true
? "border border-green-500/20 bg-green-500/10 text-green-400"
: "border border-destructive/20 bg-destructive/10 text-destructive"
}`}
>
{getUpdatableButtonText()}
</button>
</Button>
{/* Type Dropdown */}
<div className="relative">
<button
<Button
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
variant="outline"
size="default"
className={`flex items-center space-x-2 ${
filters.selectedTypes.length === 0
? "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
: "border border-cyan-300 bg-cyan-100 text-cyan-800 dark:border-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-200"
? "bg-muted text-muted-foreground hover:bg-accent"
: "border border-primary/20 bg-primary/10 text-primary"
}`}
>
<span>{getTypeButtonText()}</span>
@@ -171,54 +179,59 @@ export function FilterBar({
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</Button>
{isTypeDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800">
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2">
{SCRIPT_TYPES.map((type) => (
<label
key={type.value}
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<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-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
/>
<span className="text-lg">{type.icon}</span>
<span className="text-sm text-gray-700 dark:text-gray-300">
{type.label}
</span>
</label>
))}
{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-gray-200 p-2 dark:border-gray-700">
<button
<div className="border-t border-border p-2">
<Button
onClick={() => {
updateFilters({ selectedTypes: [] });
setIsTypeDropdownOpen(false);
}}
className="w-full rounded-md px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
variant="ghost"
size="sm"
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
>
Clear all
</button>
</Button>
</div>
</div>
)}
@@ -227,25 +240,36 @@ export function FilterBar({
{/* Sort Options */}
<div className="flex items-center space-x-2">
{/* Sort By Dropdown */}
<select
value={filters.sortBy}
onChange={(e) =>
updateFilters({ sortBy: e.target.value as "name" | "created" })
}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:focus:ring-blue-400"
>
<option value="name">📝 By Name</option>
<option value="created">📅 By Created Date</option>
</select>
<div className="relative inline-flex items-center">
<select
value={filters.sortBy}
onChange={(e) =>
updateFilters({ sortBy: e.target.value as "name" | "created" })
}
className="rounded-lg border border-input bg-background pl-9 pr-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-primary focus:outline-none appearance-none"
>
<option value="name">By Name</option>
<option value="created">By Created Date</option>
</select>
<div className="absolute left-2 pointer-events-none">
{filters.sortBy === "name" ? (
<FileText className="h-4 w-4 text-muted-foreground" />
) : (
<Calendar className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
{/* Sort Order Button */}
<button
<Button
onClick={() =>
updateFilters({
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
})
}
className="flex items-center space-x-1 rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
variant="outline"
size="default"
className="flex items-center space-x-1 bg-muted text-muted-foreground hover:bg-accent"
>
{filters.sortOrder === "asc" ? (
<>
@@ -286,20 +310,20 @@ export function FilterBar({
</span>
</>
)}
</button>
</Button>
</div>
</div>
{/* Filter Summary and Clear All */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
<div className="text-sm text-muted-foreground">
{filteredCount === totalScripts ? (
<span>Showing all {totalScripts} scripts</span>
) : (
<span>
{filteredCount} of {totalScripts} scripts{" "}
{hasActiveFilters && (
<span className="font-medium text-blue-600 dark:text-blue-400">
<span className="font-medium text-blue-600">
(filtered)
</span>
)}
@@ -308,9 +332,11 @@ export function FilterBar({
</div>
{hasActiveFilters && (
<button
<Button
onClick={clearAllFilters}
className="flex items-center space-x-1 rounded-md px-3 py-1 text-sm text-red-600 transition-colors hover:bg-red-50 hover:text-red-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:text-red-300"
variant="ghost"
size="sm"
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800"
>
<svg
className="h-4 w-4"
@@ -326,7 +352,7 @@ export function FilterBar({
/>
</svg>
<span>Clear all filters</span>
</button>
</Button>
)}
</div>

View File

@@ -3,7 +3,8 @@
import { useState } from 'react';
import { api } from '~/trpc/react';
import { Terminal } from './Terminal';
import { StatusBadge, ExecutionModeBadge } from './Badge';
import { StatusBadge } from './Badge';
import { Button } from './ui/button';
interface InstalledScript {
id: number;
@@ -15,7 +16,6 @@ interface InstalledScript {
server_ip: string | null;
server_user: string | null;
server_password: string | null;
execution_mode: 'local' | 'ssh';
installation_date: string;
status: 'in_progress' | 'success' | 'failed';
output_log: string | null;
@@ -25,7 +25,7 @@ export function InstalledScriptsTab() {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
const [serverFilter, setServerFilter] = useState<string>('all');
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any; mode: 'local' | 'ssh' } | null>(null);
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
const [showAddForm, setShowAddForm] = useState(false);
@@ -80,7 +80,7 @@ export function InstalledScriptsTab() {
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
const matchesServer = serverFilter === 'all' ||
(serverFilter === 'local' && script.execution_mode === 'local') ||
(serverFilter === 'local' && !script.server_name) ||
(script.server_name === serverFilter);
return matchesSearch && matchesStatus && matchesServer;
@@ -111,7 +111,7 @@ export function InstalledScriptsTab() {
if (confirm(`Are you sure you want to update ${script.script_name}?`)) {
// Get server info if it's SSH mode
let server = null;
if (script.execution_mode === 'ssh' && script.server_id && script.server_user && script.server_password) {
if (script.server_id && script.server_user && script.server_password) {
server = {
id: script.server_id,
name: script.server_name,
@@ -124,8 +124,7 @@ export function InstalledScriptsTab() {
setUpdatingScript({
id: script.id,
containerId: script.container_id,
server: server,
mode: script.execution_mode
server: server
});
}
};
@@ -205,7 +204,7 @@ export function InstalledScriptsTab() {
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400">Loading installed scripts...</div>
<div className="text-muted-foreground">Loading installed scripts...</div>
</div>
);
}
@@ -218,7 +217,7 @@ export function InstalledScriptsTab() {
<Terminal
scriptPath={`update-${updatingScript.containerId}`}
onClose={handleCloseUpdateTerminal}
mode={updatingScript.mode}
mode={updatingScript.server ? 'ssh' : 'local'}
server={updatingScript.server}
isUpdate={true}
containerId={updatingScript.containerId}
@@ -227,79 +226,80 @@ export function InstalledScriptsTab() {
)}
{/* Header with Stats */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">Installed Scripts</h2>
<div className="bg-card rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{stats.total}</div>
<div className="text-sm text-blue-800">Total Installations</div>
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-400">{stats.total}</div>
<div className="text-sm text-blue-300">Total Installations</div>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-600">{stats.byStatus.success}</div>
<div className="text-sm text-green-800">Successful</div>
<div className="bg-green-500/10 border border-green-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-400">{stats.byStatus.success}</div>
<div className="text-sm text-green-300">Successful</div>
</div>
<div className="bg-red-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-red-600">{stats.byStatus.failed}</div>
<div className="text-sm text-red-800">Failed</div>
<div className="bg-red-500/10 border border-red-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-red-400">{stats.byStatus.failed}</div>
<div className="text-sm text-red-300">Failed</div>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{stats.byStatus.in_progress}</div>
<div className="text-sm text-yellow-800">In Progress</div>
<div className="bg-yellow-500/10 border border-yellow-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-yellow-400">{stats.byStatus.in_progress}</div>
<div className="text-sm text-yellow-300">In Progress</div>
</div>
</div>
)}
{/* Add Script Button */}
<div className="mb-4">
<button
<Button
onClick={() => setShowAddForm(!showAddForm)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
variant={showAddForm ? "outline" : "default"}
size="default"
>
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
</button>
</Button>
</div>
{/* Add Script Form */}
{showAddForm && (
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Add Manual Script Entry</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className="mb-6 p-6 bg-card rounded-lg border border-border shadow-sm">
<h3 className="text-lg font-semibold text-foreground mb-6">Add Manual Script Entry</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Script Name *
</label>
<input
type="text"
value={addFormData.script_name}
onChange={(e) => handleAddFormChange('script_name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Enter script name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Container ID
</label>
<input
type="text"
value={addFormData.container_id}
onChange={(e) => handleAddFormChange('container_id', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Enter container ID"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Server
</label>
<select
value={addFormData.server_id}
onChange={(e) => handleAddFormChange('server_id', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
>
<option value="local">Select Server (Local if none)</option>
<option value="local">Select Server</option>
{serversData?.servers?.map((server: any) => (
<option key={server.id} value={server.id}>
{server.name}
@@ -308,20 +308,22 @@ export function InstalledScriptsTab() {
</select>
</div>
</div>
<div className="flex justify-end space-x-3 mt-4">
<button
<div className="flex justify-end space-x-3 mt-6">
<Button
onClick={handleCancelAdd}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-md hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
variant="outline"
size="default"
>
Cancel
</button>
<button
</Button>
<Button
onClick={handleAddScript}
disabled={createScriptMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
variant="default"
size="default"
>
{createScriptMutation.isPending ? 'Adding...' : 'Add Script'}
</button>
</Button>
</div>
</div>
)}
@@ -334,14 +336,14 @@ export function InstalledScriptsTab() {
placeholder="Search scripts, container IDs, or servers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
className="px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="all">All Status</option>
<option value="success">Success</option>
@@ -352,7 +354,7 @@ export function InstalledScriptsTab() {
<select
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
className="px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="all">All Servers</option>
<option value="local">Local</option>
@@ -364,42 +366,39 @@ export function InstalledScriptsTab() {
</div>
{/* Scripts Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="bg-card rounded-lg shadow overflow-hidden">
{filteredScripts.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-center py-8 text-muted-foreground">
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Script Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Container ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Server
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Mode
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Installation Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<tbody className="bg-card divide-y divide-gray-200">
{filteredScripts.map((script) => (
<tr key={script.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<tr key={script.id} className="hover:bg-accent">
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="space-y-2">
@@ -407,15 +406,15 @@ export function InstalledScriptsTab() {
type="text"
value={editFormData.script_name}
onChange={(e) => handleInputChange('script_name', e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Script name"
/>
<div className="text-xs text-gray-500 dark:text-gray-400">{script.script_path}</div>
<div className="text-xs text-muted-foreground">{script.script_path}</div>
</div>
) : (
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{script.script_name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{script.script_path}</div>
<div className="text-sm font-medium text-foreground">{script.script_name}</div>
<div className="text-sm text-muted-foreground">{script.script_path}</div>
</div>
)}
</td>
@@ -425,81 +424,76 @@ export function InstalledScriptsTab() {
type="text"
value={editFormData.container_id}
onChange={(e) => handleInputChange('container_id', e.target.value)}
className="w-full px-2 py-1 text-sm font-mono border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Container ID"
/>
) : (
script.container_id ? (
<span className="text-sm font-mono text-gray-900 dark:text-gray-100">{String(script.container_id)}</span>
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500">-</span>
<span className="text-sm text-muted-foreground">-</span>
)
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{script.execution_mode === 'local' ? (
<span className="text-sm text-gray-900 dark:text-gray-100">Local</span>
) : (
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{script.server_name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{script.server_ip}</div>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<ExecutionModeBadge mode={script.execution_mode}>
{script.execution_mode.toUpperCase()}
</ExecutionModeBadge>
<span className="text-sm text-muted-foreground">
{script.server_name ?? 'Local'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={script.status}>
{script.status.replace('_', ' ').toUpperCase()}
</StatusBadge>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{formatDate(String(script.installation_date))}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
{editingScriptId === script.id ? (
<>
<button
<Button
onClick={handleSaveEdit}
disabled={updateScriptMutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm font-medium disabled:opacity-50"
variant="default"
size="sm"
>
{updateScriptMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button
</Button>
<Button
onClick={handleCancelEdit}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm font-medium"
variant="outline"
size="sm"
>
Cancel
</button>
</Button>
</>
) : (
<>
<button
<Button
onClick={() => handleEditScript(script)}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm font-medium"
variant="default"
size="sm"
>
Edit
</button>
</Button>
{script.container_id && (
<button
<Button
onClick={() => handleUpdateScript(script)}
className="text-blue-600 hover:text-blue-900"
variant="link"
size="sm"
>
Update
</button>
</Button>
)}
<button
<Button
onClick={() => handleDeleteScript(Number(script.id))}
className="text-red-600 hover:text-red-900"
variant="destructive"
size="sm"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</button>
</Button>
</>
)}
</div>

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
return (
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg dark:hover:shadow-xl transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 h-full flex flex-col"
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col"
onClick={() => onClick(script)}
>
<div className="p-6 flex-1 flex flex-col">
@@ -36,15 +36,15 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
onError={handleImageError}
/>
) : (
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400 text-lg font-semibold">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
<span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate">
<h3 className="text-lg font-semibold text-foreground truncate">
{script.name || 'Unnamed Script'}
</h3>
<div className="mt-2 space-y-2">
@@ -60,7 +60,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
}`}></div>
<span className={`text-xs font-medium ${
script.isDownloaded ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'
script.isDownloaded ? 'text-green-700' : 'text-destructive'
}`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
</span>
@@ -70,7 +70,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
</div>
{/* Description */}
<p className="text-gray-600 dark:text-gray-300 text-sm line-clamp-3 mb-4 flex-1">
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
{script.description || 'No description available'}
</p>
@@ -81,7 +81,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm font-medium flex items-center space-x-1"
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1"
onClick={(e) => e.stopPropagation()}
>
<span>Website</span>

View File

@@ -8,6 +8,7 @@ import { DiffViewer } from "./DiffViewer";
import { TextViewer } from "./TextViewer";
import { ExecutionModeModal } from "./ExecutionModeModal";
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
import { Button } from "./ui/button";
interface ScriptDetailModalProps {
script: Script | null;
@@ -62,20 +63,20 @@ export function ScriptDetailModal({
if (data.success) {
const message =
"message" in data ? data.message : "Script loaded successfully";
setLoadMessage(` ${message}`);
setLoadMessage(`[SUCCESS] ${message}`);
// Refetch script files status and comparison data to update the UI
void refetchScriptFiles();
void refetchComparison();
} else {
const error = "error" in data ? data.error : "Failed to load script";
setLoadMessage(` ${error}`);
setLoadMessage(`[ERROR] ${error}`);
}
// Clear message after 5 seconds
setTimeout(() => setLoadMessage(null), 5000);
},
onError: (error) => {
setIsLoading(false);
setLoadMessage(`❌ Error: ${error.message}`);
setLoadMessage(`[ERROR] ${error.message}`);
setTimeout(() => setLoadMessage(null), 5000);
},
});
@@ -132,12 +133,12 @@ export function ScriptDetailModal({
return (
<div
className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black p-4"
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
onClick={handleBackdropClick}
>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto">
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
<div className="flex items-center justify-between border-b border-border p-6">
<div className="flex items-center space-x-4">
{script.logo && !imageError ? (
<Image
@@ -149,14 +150,14 @@ export function ScriptDetailModal({
onError={handleImageError}
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-gray-200 dark:bg-gray-700">
<span className="text-2xl font-semibold text-gray-500 dark:text-gray-400">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-muted">
<span className="text-2xl font-semibold text-muted-foreground">
{script.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<h2 className="text-2xl font-bold text-foreground">
{script.name}
</h2>
<div className="mt-1 flex items-center space-x-2">
@@ -171,9 +172,11 @@ export function ScriptDetailModal({
{scriptFilesData?.success &&
scriptFilesData.ctExists &&
onInstallScript && (
<button
<Button
onClick={handleInstallScript}
className="flex items-center space-x-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
variant="outline"
size="default"
className="flex items-center space-x-2"
>
<svg
className="h-4 w-4"
@@ -189,15 +192,17 @@ export function ScriptDetailModal({
/>
</svg>
<span>Install</span>
</button>
</Button>
)}
{/* View Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<button
<Button
onClick={handleViewScript}
className="flex items-center space-x-2 rounded-lg bg-purple-600 px-4 py-2 font-medium text-white transition-colors hover:bg-purple-700"
variant="outline"
size="default"
className="flex items-center space-x-2 "
>
<svg
className="h-4 w-4"
@@ -219,7 +224,7 @@ export function ScriptDetailModal({
/>
</svg>
<span>View</span>
</button>
</Button>
)}
{/* Load/Update Script Button */}
@@ -239,7 +244,7 @@ export function ScriptDetailModal({
disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? "cursor-not-allowed bg-gray-400 text-white"
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-green-600 text-white hover:bg-green-700"
}`}
>
@@ -273,7 +278,7 @@ export function ScriptDetailModal({
return (
<button
disabled
className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-gray-400 px-4 py-2 font-medium text-white transition-colors"
className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-muted px-4 py-2 font-medium text-muted-foreground transition-colors"
>
<svg
className="h-4 w-4"
@@ -299,7 +304,7 @@ export function ScriptDetailModal({
disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? "cursor-not-allowed bg-gray-400 text-white"
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-orange-600 text-white hover:bg-orange-700"
}`}
>
@@ -330,9 +335,11 @@ export function ScriptDetailModal({
);
}
})()}
<button
<Button
onClick={onClose}
className="text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg
className="h-6 w-6"
@@ -347,20 +354,20 @@ export function ScriptDetailModal({
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Button>
</div>
</div>
{/* Load Message */}
{loadMessage && (
<div className="mx-6 mb-4 rounded-lg bg-blue-50 p-3 text-sm text-blue-800">
<div className="mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
{loadMessage}
</div>
)}
{/* Script Files Status */}
{(scriptFilesLoading || comparisonLoading) && (
<div className="mx-6 mb-4 rounded-lg bg-blue-50 p-3 text-sm">
<div className="mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
<span>Loading script status...</span>
@@ -385,11 +392,11 @@ export function ScriptDetailModal({
}
return (
<div className="mx-6 mb-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-700 dark:text-gray-300">
<div className="mx-6 mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-gray-300"}`}
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-muted"}`}
></div>
<span>
{scriptType}:{" "}
@@ -398,7 +405,7 @@ export function ScriptDetailModal({
</div>
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-green-500" : "bg-gray-300"}`}
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-green-500" : "bg-muted"}`}
></div>
<span>
Install Script:{" "}
@@ -426,7 +433,7 @@ export function ScriptDetailModal({
)}
</div>
{scriptFilesData.files.length > 0 && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
<div className="mt-2 text-xs text-muted-foreground">
Files: {scriptFilesData.files.join(", ")}
</div>
)}
@@ -438,10 +445,10 @@ export function ScriptDetailModal({
<div className="space-y-6 p-6">
{/* Description */}
<div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
<h3 className="mb-2 text-lg font-semibold text-foreground">
Description
</h3>
<p className="text-gray-600 dark:text-gray-300">
<p className="text-muted-foreground">
{script.description}
</p>
</div>
@@ -449,50 +456,50 @@ export function ScriptDetailModal({
{/* Basic Information */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
<h3 className="mb-3 text-lg font-semibold text-foreground">
Basic Information
</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt className="text-sm font-medium text-muted-foreground">
Slug
</dt>
<dd className="font-mono text-sm text-gray-900 dark:text-gray-100">
<dd className="font-mono text-sm text-foreground">
{script.slug}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt className="text-sm font-medium text-muted-foreground">
Date Created
</dt>
<dd className="text-sm text-gray-900 dark:text-gray-100">
<dd className="text-sm text-foreground">
{script.date_created}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt className="text-sm font-medium text-muted-foreground">
Categories
</dt>
<dd className="text-sm text-gray-900 dark:text-gray-100">
<dd className="text-sm text-foreground">
{script.categories.join(", ")}
</dd>
</div>
{script.interface_port && (
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt className="text-sm font-medium text-muted-foreground">
Interface Port
</dt>
<dd className="text-sm text-gray-900 dark:text-gray-100">
<dd className="text-sm text-foreground">
{script.interface_port}
</dd>
</div>
)}
{script.config_path && (
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt className="text-sm font-medium text-muted-foreground">
Config Path
</dt>
<dd className="font-mono text-sm text-gray-900 dark:text-gray-100">
<dd className="font-mono text-sm text-foreground">
{script.config_path}
</dd>
</div>
@@ -501,13 +508,13 @@ export function ScriptDetailModal({
</div>
<div>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
<h3 className="mb-3 text-lg font-semibold text-foreground">
Links
</h3>
<dl className="space-y-2">
{script.website && (
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt className="text-sm font-medium text-muted-foreground">
Website
</dt>
<dd className="text-sm">
@@ -515,7 +522,7 @@ export function ScriptDetailModal({
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="break-all text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
className="break-all text-primary hover:text-primary/80"
>
{script.website}
</a>
@@ -524,7 +531,7 @@ export function ScriptDetailModal({
)}
{script.documentation && (
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<dt className="text-sm font-medium text-muted-foreground">
Documentation
</dt>
<dd className="text-sm">
@@ -532,7 +539,7 @@ export function ScriptDetailModal({
href={script.documentation}
target="_blank"
rel="noopener noreferrer"
className="break-all text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
className="break-all text-primary hover:text-primary/80"
>
{script.documentation}
</a>
@@ -548,53 +555,53 @@ export function ScriptDetailModal({
script.type !== "pve" &&
script.type !== "addon" && (
<div>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
<h3 className="mb-3 text-lg font-semibold text-foreground">
Install Methods
</h3>
<div className="space-y-4">
{script.install_methods.map((method, index) => (
<div
key={index}
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-600 dark:bg-gray-700"
className="rounded-lg border border-border bg-card p-4"
>
<div className="mb-3 flex items-center justify-between">
<h4 className="font-medium text-gray-900 capitalize dark:text-gray-100">
<h4 className="font-medium text-foreground capitalize">
{method.type}
</h4>
<span className="font-mono text-sm text-gray-500 dark:text-gray-400">
<span className="font-mono text-sm text-muted-foreground">
{method.script}
</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">
<dt className="font-medium text-muted-foreground">
CPU
</dt>
<dd className="text-gray-900 dark:text-gray-100">
<dd className="text-foreground">
{method.resources.cpu} cores
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">
<dt className="font-medium text-muted-foreground">
RAM
</dt>
<dd className="text-gray-900 dark:text-gray-100">
<dd className="text-foreground">
{method.resources.ram} MB
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">
<dt className="font-medium text-muted-foreground">
HDD
</dt>
<dd className="text-gray-900 dark:text-gray-100">
<dd className="text-foreground">
{method.resources.hdd} GB
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">
<dt className="font-medium text-muted-foreground">
OS
</dt>
<dd className="text-gray-900 dark:text-gray-100">
<dd className="text-foreground">
{method.resources.os} {method.resources.version}
</dd>
</div>
@@ -609,26 +616,26 @@ export function ScriptDetailModal({
{(script.default_credentials.username ??
script.default_credentials.password) && (
<div>
<h3 className="mb-3 text-lg font-semibold text-gray-900">
<h3 className="mb-3 text-lg font-semibold text-foreground">
Default Credentials
</h3>
<dl className="space-y-2">
{script.default_credentials.username && (
<div>
<dt className="text-sm font-medium text-gray-500">
<dt className="text-sm font-medium text-muted-foreground">
Username
</dt>
<dd className="font-mono text-sm text-gray-900">
<dd className="font-mono text-sm text-foreground">
{script.default_credentials.username}
</dd>
</div>
)}
{script.default_credentials.password && (
<div>
<dt className="text-sm font-medium text-gray-500">
<dt className="text-sm font-medium text-muted-foreground">
Password
</dt>
<dd className="font-mono text-sm text-gray-900">
<dd className="font-mono text-sm text-foreground">
{script.default_credentials.password}
</dd>
</div>
@@ -640,7 +647,7 @@ export function ScriptDetailModal({
{/* Notes */}
{script.notes.length > 0 && (
<div>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
<h3 className="mb-3 text-lg font-semibold text-foreground">
Notes
</h3>
<ul className="space-y-2">
@@ -655,10 +662,10 @@ export function ScriptDetailModal({
key={index}
className={`rounded-lg p-3 text-sm ${
noteType === "warning"
? "border-l-4 border-yellow-400 bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200"
? "border-l-4 border-yellow-400 bg-yellow-500/10 text-yellow-400"
: noteType === "error"
? "border-l-4 border-red-400 bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200"
: "bg-gray-50 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
? "border-l-4 border-destructive bg-destructive/10 text-destructive"
: "bg-muted text-muted-foreground"
}`}
>
<div className="flex items-start">

View File

@@ -6,6 +6,7 @@ import { ScriptCard } from './ScriptCard';
import { ScriptDetailModal } from './ScriptDetailModal';
import { CategorySidebar } from './CategorySidebar';
import { FilterBar, type FilterState } from './FilterBar';
import { Button } from './ui/button';
import type { ScriptCard as ScriptCardType } from '~/types/script';
@@ -269,7 +270,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">Loading scripts...</span>
<span className="ml-2 text-muted-foreground">Loading scripts...</span>
</div>
);
}
@@ -282,16 +283,18 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p className="text-lg font-medium">Failed to load scripts</p>
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-muted-foreground mt-1">
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
</p>
</div>
<button
<Button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
variant="default"
size="default"
className="mt-4"
>
Try Again
</button>
</Button>
</div>
);
}
@@ -299,12 +302,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
if (!scriptsWithStatus || scriptsWithStatus.length === 0) {
return (
<div className="text-center py-12">
<div className="text-gray-500">
<div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-lg font-medium">No scripts found</p>
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-muted-foreground mt-1">
No script files were found in the repository or local directory.
</p>
</div>
@@ -340,7 +343,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<div className="hidden mb-8">
<div className="relative max-w-md mx-auto">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
@@ -349,12 +352,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
placeholder="Search scripts by name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-800 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-gray-100 focus:outline-none focus:placeholder-gray-400 dark:focus:placeholder-gray-300 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 text-sm"
className="block w-full pl-10 pr-3 py-3 border border-border rounded-lg leading-5 bg-card placeholder-muted-foreground text-foreground focus:outline-none focus:placeholder-muted-foreground focus:ring-2 focus:ring-ring focus:border-ring text-sm"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
className="absolute inset-y-0 right-0 pr-3 flex items-center text-muted-foreground hover:text-foreground"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -363,7 +366,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
)}
</div>
{(searchQuery || selectedCategory) && (
<div className="text-center mt-2 text-sm text-gray-600">
<div className="text-center mt-2 text-sm text-muted-foreground">
{filteredScripts.length === 0 ? (
<span>No scripts found{searchQuery ? ` matching "${searchQuery}"` : ''}{selectedCategory ? ` in category "${selectedCategory}"` : ''}</span>
) : (
@@ -380,30 +383,32 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{/* Scripts Grid */}
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
<div className="text-center py-12">
<div className="text-gray-500">
<div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p className="text-lg font-medium">No matching scripts found</p>
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-muted-foreground mt-1">
Try different filter settings or clear all filters.
</p>
<div className="flex justify-center gap-2 mt-4">
{filters.searchQuery && (
<button
<Button
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
variant="default"
size="default"
>
Clear Search
</button>
</Button>
)}
{selectedCategory && (
<button
<Button
onClick={() => handleCategorySelect(null)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
variant="secondary"
size="default"
>
Clear Category
</button>
</Button>
)}
</div>
</div>

View File

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

View File

@@ -3,6 +3,7 @@
import { useState } from 'react';
import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm';
import { Button } from './ui/button';
interface ServerListProps {
servers: Server[];
@@ -71,12 +72,12 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
if (servers.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="text-center py-8 text-muted-foreground">
<svg className="mx-auto h-12 w-12 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No servers configured</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding a new server configuration above.</p>
<h3 className="mt-2 text-sm font-medium text-foreground">No servers configured</h3>
<p className="mt-1 text-sm text-muted-foreground">Get started by adding a new server configuration above.</p>
</div>
);
}
@@ -84,10 +85,10 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
return (
<div className="space-y-4">
{servers.map((server) => (
<div key={server.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div key={server.id} className="bg-card border border-border rounded-lg p-4 shadow-sm">
{editingId === server.id ? (
<div>
<h4 className="text-lg font-medium text-gray-900 mb-4">Edit Server</h4>
<h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
<ServerForm
initialData={{
name: server.name,
@@ -112,8 +113,8 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">{server.name}</h3>
<div className="mt-1 flex items-center space-x-4 text-sm text-gray-500">
<h3 className="text-lg font-medium text-foreground truncate">{server.name}</h3>
<div className="mt-1 flex items-center space-x-4 text-sm text-muted-foreground">
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
@@ -127,7 +128,7 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
{server.user}
</span>
</div>
<div className="mt-1 text-xs text-gray-400">
<div className="mt-1 text-xs text-muted-foreground">
Created: {new Date(server.created_at).toLocaleDateString()}
{server.updated_at !== server.created_at && (
<span> Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
@@ -162,10 +163,12 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
</div>
</div>
<div className="flex items-center space-x-2">
<button
<Button
onClick={() => handleTestConnection(server)}
disabled={testingConnections.has(server.id)}
className="inline-flex items-center px-3 py-1.5 border border-green-300 text-xs font-medium rounded text-green-700 bg-white hover:bg-green-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
variant="outline"
size="sm"
className="border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
>
{testingConnections.has(server.id) ? (
<>
@@ -182,25 +185,28 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
Test Connection
</>
)}
</button>
<button
</Button>
<Button
onClick={() => handleEdit(server)}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
variant="outline"
size="sm"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
<button
</Button>
<Button
onClick={() => handleDelete(server.id)}
className="inline-flex items-center px-3 py-1.5 border border-red-300 text-xs font-medium rounded text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
variant="outline"
size="sm"
className="border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
</Button>
</div>
</div>
)}

View File

@@ -2,6 +2,7 @@
import { useState } from 'react';
import { SettingsModal } from './SettingsModal';
import { Button } from './ui/button';
export function SettingsButton() {
const [isOpen, setIsOpen] = useState(false);
@@ -9,12 +10,14 @@ export function SettingsButton() {
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-gray-600 dark:text-gray-300 font-medium">
Add and manage PVE Servers
<div className="text-sm text-muted-foreground font-medium">
Add and manage PVE Servers:
</div>
<button
<Button
onClick={() => setIsOpen(true)}
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-200"
variant="outline"
size="default"
className="inline-flex items-center"
title="Add PVE Server"
>
<svg
@@ -38,7 +41,7 @@ export function SettingsButton() {
/>
</svg>
Manage PVE Servers
</button>
</Button>
</div>
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />

View File

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

View File

@@ -2,6 +2,8 @@
import { useEffect, useRef, useState } from 'react';
import '@xterm/xterm/css/xterm.css';
import { Button } from './ui/button';
import { Play, Square, Trash2, X } from 'lucide-react';
interface TerminalProps {
scriptPath: string;
@@ -57,7 +59,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
cursor: '#00ff00',
},
fontSize: 14,
fontFamily: 'Courier New, monospace',
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
cursorBlink: true,
cursorStyle: 'block',
scrollback: 1000,
@@ -214,7 +216,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
switch (message.type) {
case 'start':
xtermRef.current.writeln(`${prefix}🚀 ${message.data}`);
xtermRef.current.writeln(`${prefix}[START] ${message.data}`);
setIsRunning(true);
break;
case 'output':
@@ -231,14 +233,14 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
xtermRef.current.write(message.data);
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
// This is a script error, show it with error prefix
xtermRef.current.writeln(`${prefix} ${message.data}`);
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
} else {
// This is a real error, show it with error prefix
xtermRef.current.writeln(`${prefix} ${message.data}`);
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
}
break;
case 'end':
xtermRef.current.writeln(`${prefix} ${message.data}`);
xtermRef.current.writeln(`${prefix}[SUCCESS] ${message.data}`);
setIsRunning(false);
break;
}
@@ -276,44 +278,44 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// Don't render on server side
if (!isClient) {
return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700">
<div className="bg-card rounded-lg border border-border overflow-hidden">
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<span className="text-gray-300 font-mono text-sm ml-2">
<span className="text-foreground font-mono text-sm ml-2">
{scriptName}
</span>
</div>
</div>
<div className="h-96 w-full flex items-center justify-center">
<div className="text-gray-400">Loading terminal...</div>
<div className="text-muted-foreground">Loading terminal...</div>
</div>
</div>
);
}
return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
<div className="bg-card rounded-lg border border-border overflow-hidden">
{/* Terminal Header */}
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700">
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<span className="text-gray-300 font-mono text-sm ml-2">
<span className="text-foreground font-mono text-sm ml-2">
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
</span>
</div>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-gray-400 text-xs">
<span className="text-muted-foreground text-xs">
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
@@ -322,51 +324,55 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
{/* Terminal Output */}
<div
ref={terminalRef}
className="h-96 w-full"
style={{ minHeight: '384px' }}
className="h-[32rem] w-full max-w-4xl mx-auto"
style={{ minHeight: '512px' }}
/>
{/* Terminal Controls */}
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-t border-gray-700">
<div className="bg-muted px-4 py-2 flex items-center justify-between border-t border-border">
<div className="flex space-x-2">
<button
<Button
onClick={startScript}
disabled={!isConnected || isRunning}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
isConnected && !isRunning
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
variant="default"
size="sm"
className={isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
>
Start
</button>
<Play className="h-4 w-4 mr-1" />
Start
</Button>
<button
<Button
onClick={stopScript}
disabled={!isRunning}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
isRunning
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
variant="default"
size="sm"
className={isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
>
Stop
</button>
<Square className="h-4 w-4 mr-1" />
Stop
</Button>
<button
<Button
onClick={clearOutput}
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
variant="secondary"
size="sm"
className="bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
🗑 Clear
</button>
<Trash2 className="h-4 w-4 mr-1" />
Clear
</Button>
</div>
<button
<Button
onClick={onClose}
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
variant="secondary"
size="sm"
className="bg-gray-600 text-white hover:bg-gray-700"
>
Close
</button>
<X className="h-4 w-4 mr-1" />
Close
</Button>
</div>
</div>
);

View File

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

View File

@@ -0,0 +1,234 @@
'use client';
import { api } from "~/trpc/react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { ExternalLink, Download, RefreshCw, Loader2, Check } from "lucide-react";
import { useState } from "react";
// Loading overlay component
function LoadingOverlay({ isNetworkError = false }: { isNetworkError?: boolean }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-2xl border border-gray-200 dark:border-gray-700 max-w-md mx-4">
<div className="flex flex-col items-center space-y-4">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-blue-600 dark:text-blue-400" />
<div className="absolute inset-0 rounded-full border-2 border-blue-200 dark:border-blue-800 animate-pulse"></div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{isNetworkError
? 'The server is restarting after the update...'
: 'Please stand by while we update your application...'
}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">
{isNetworkError
? 'This may take a few moments. The page will reload automatically. You may see a blank page for up to a minute!.'
: 'The server will restart automatically when complete.'
}
</p>
</div>
<div className="flex space-x-1">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
</div>
);
}
export function VersionDisplay() {
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
const [isUpdating, setIsUpdating] = useState(false);
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
const [isNetworkError, setIsNetworkError] = useState(false);
const executeUpdate = api.version.executeUpdate.useMutation({
onSuccess: (result: any) => {
const now = Date.now();
const elapsed = updateStartTime ? now - updateStartTime : 0;
setUpdateResult({ success: result.success, message: result.message });
if (result.success) {
// The script now runs independently, so we show a longer overlay
// and wait for the server to restart
setIsNetworkError(true);
setUpdateResult({ success: true, message: 'Update in progress... Server will restart automatically.' });
// Wait longer for the update to complete and server to restart
setTimeout(() => {
setIsUpdating(false);
setIsNetworkError(false);
// Try to reload after the update completes
setTimeout(() => {
window.location.reload();
}, 10000); // 10 seconds to allow for update completion
}, 5000); // Show overlay for 5 seconds
} else {
// For errors, show for at least 1 second
const remainingTime = Math.max(0, 1000 - elapsed);
setTimeout(() => {
setIsUpdating(false);
}, remainingTime);
}
},
onError: (error) => {
const now = Date.now();
const elapsed = updateStartTime ? now - updateStartTime : 0;
// Check if this is a network error (expected during server restart)
const isNetworkError = error.message.includes('Failed to fetch') ||
error.message.includes('NetworkError') ||
error.message.includes('fetch') ||
error.message.includes('network');
if (isNetworkError && elapsed < 60000) { // If it's a network error within 30 seconds, treat as success
setIsNetworkError(true);
setUpdateResult({ success: true, message: 'Update in progress... Server is restarting.' });
// Wait longer for server to come back up
setTimeout(() => {
setIsUpdating(false);
setIsNetworkError(false);
// Try to reload after a longer delay
setTimeout(() => {
window.location.reload();
}, 5000);
}, 3000);
} else {
// For real errors, show for at least 1 second
setUpdateResult({ success: false, message: error.message });
const remainingTime = Math.max(0, 1000 - elapsed);
setTimeout(() => {
setIsUpdating(false);
}, remainingTime);
}
}
});
const handleUpdate = () => {
setIsUpdating(true);
setUpdateResult(null);
setIsNetworkError(false);
setUpdateStartTime(Date.now());
executeUpdate.mutate();
};
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="animate-pulse">
Loading...
</Badge>
</div>
);
}
if (error || !versionStatus?.success) {
return (
<div className="flex items-center gap-2">
<Badge variant="destructive">
v{versionStatus?.currentVersion ?? 'Unknown'}
</Badge>
<span className="text-xs text-muted-foreground">
(Unable to check for updates)
</span>
</div>
);
}
const { currentVersion, isUpToDate, updateAvailable, releaseInfo } = versionStatus;
return (
<>
{/* Loading overlay */}
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} />}
<div className="flex items-center gap-2">
<Badge variant={isUpToDate ? "default" : "secondary"}>
v{currentVersion}
</Badge>
{updateAvailable && releaseInfo && (
<div className="flex items-center gap-3">
<div className="relative group">
<Badge variant="destructive" className="animate-pulse cursor-help">
Update Available
</Badge>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10">
<div className="text-center">
<div className="font-semibold mb-1">How to update:</div>
<div>Click the button to update</div>
<div>or update manually:</div>
<div>cd $PVESCRIPTLOCAL_DIR</div>
<div>git pull</div>
<div>npm install</div>
<div>npm run build</div>
<div>npm start</div>
</div>
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"></div>
</div>
</div>
<Button
onClick={handleUpdate}
disabled={isUpdating}
size="sm"
variant="destructive"
className="text-xs h-6 px-2"
>
{isUpdating ? (
<>
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
Updating...
</>
) : (
<>
<Download className="h-3 w-3 mr-1" />
Update Now
</>
)}
</Button>
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="View latest release"
>
<ExternalLink className="h-3 w-3" />
</a>
{updateResult && (
<div className={`text-xs px-2 py-1 rounded ${
updateResult.success
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
}`}>
{updateResult.message}
</div>
)}
</div>
)}
{isUpToDate && (
<span className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<Check className="h-3 w-3" />
Up to date
</span>
)}
</div>
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,6 @@ import { type Metadata } from "next";
import { Geist } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react";
import { DarkModeProvider } from "./_components/DarkModeProvider";
import { DarkModeToggle } from "./_components/DarkModeToggle";
export const metadata: Metadata = {
title: "PVE Scripts local",
@@ -19,54 +17,29 @@ export const metadata: Metadata = {
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
variable: "--font-jetbrains-mono",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable}`}>
<html lang="en" className={`${geist.variable} dark`}>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const stored = localStorage.getItem('theme');
const theme = stored && ['light', 'dark', 'system'].includes(stored) ? stored : 'system';
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = theme === 'dark' || (theme === 'system' && systemDark);
if (shouldBeDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {
// Fallback to system preference if localStorage fails
const systemDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
if (systemDark) {
document.documentElement.classList.add('dark');
}
}
})();
// Force dark mode
document.documentElement.classList.add('dark');
`,
}}
/>
</head>
<body
className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors"
className="bg-background text-foreground transition-colors"
suppressHydrationWarning={true}
>
<DarkModeProvider>
{/* Dark Mode Toggle in top right corner */}
<div className="fixed top-4 right-4 z-50">
<DarkModeToggle />
</div>
<TRPCReactProvider>{children}</TRPCReactProvider>
</DarkModeProvider>
<TRPCReactProvider>{children}</TRPCReactProvider>
</body>
</html>
);

View File

@@ -3,14 +3,18 @@
import { useState } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid';
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
import { ResyncButton } from './_components/ResyncButton';
import { Terminal } from './_components/Terminal';
import { SettingsButton } from './_components/SettingsButton';
import { VersionDisplay } from './_components/VersionDisplay';
import { Button } from './_components/ui/button';
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
export default function Home() {
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'installed'>('scripts');
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
@@ -21,21 +25,25 @@ export default function Home() {
};
return (
<main className="min-h-screen bg-gray-100 dark:bg-gray-900">
<main className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 dark:text-gray-100 mb-2">
🚀 PVE Scripts Management
<h1 className="text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-3">
<Rocket className="h-9 w-9" />
PVE Scripts Management
</h1>
<p className="text-gray-600 dark:text-gray-300">
<p className="text-muted-foreground mb-4">
Manage and execute Proxmox helper scripts locally with live output streaming
</p>
<div className="flex justify-center">
<VersionDisplay />
</div>
</div>
{/* Controls */}
<div className="mb-8">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 p-6 bg-card rounded-lg shadow-sm border border-border">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<SettingsButton />
</div>
@@ -47,28 +55,44 @@ export default function Home() {
{/* Tab Navigation */}
<div className="mb-8">
<div className="border-b border-gray-200 dark:border-gray-700">
<div className="border-b border-border">
<nav className="-mb-px flex space-x-8">
<button
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
className={`px-3 py-1 text-sm flex items-center gap-2 ${
activeTab === 'scripts'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
📦 Available Scripts
</button>
<button
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
}`}>
<Package className="h-4 w-4" />
Available Scripts
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('downloaded')}
className={`px-3 py-1 text-sm flex items-center gap-2 ${
activeTab === 'downloaded'
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
}`}>
<HardDrive className="h-4 w-4" />
Downloaded Scripts
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
className={`px-3 py-1 text-sm flex items-center gap-2 ${
activeTab === 'installed'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
🗂 Installed Scripts
</button>
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
}`}>
<FolderOpen className="h-4 w-4" />
Installed Scripts
</Button>
</nav>
</div>
</div>
@@ -92,6 +116,10 @@ export default function Home() {
<ScriptsGrid onInstallScript={handleRunScript} />
)}
{activeTab === 'downloaded' && (
<DownloadedScriptsTab />
)}
{activeTab === 'installed' && (
<InstalledScriptsTab />
)}

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

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

View File

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

View File

@@ -0,0 +1,148 @@
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { readFile } from "fs/promises";
import { join } from "path";
import { spawn } from "child_process";
interface GitHubRelease {
tag_name: string;
name: string;
published_at: string;
html_url: string;
}
export const versionRouter = createTRPCRouter({
// Get current local version
getCurrentVersion: publicProcedure
.query(async () => {
try {
const versionPath = join(process.cwd(), 'VERSION');
const version = await readFile(versionPath, 'utf-8');
return {
success: true,
version: version.trim()
};
} catch (error) {
console.error('Error reading VERSION file:', error);
return {
success: false,
error: 'Failed to read VERSION file',
version: null
};
}
}),
getLatestRelease: publicProcedure
.query(async () => {
try {
const response = await fetch('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const release: GitHubRelease = await response.json();
return {
success: true,
release: {
tagName: release.tag_name,
name: release.name,
publishedAt: release.published_at,
htmlUrl: release.html_url
}
};
} catch (error) {
console.error('Error fetching latest release:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch latest release',
release: null
};
}
}),
getVersionStatus: publicProcedure
.query(async () => {
try {
const versionPath = join(process.cwd(), 'VERSION');
const currentVersion = (await readFile(versionPath, 'utf-8')).trim();
const response = await fetch('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const release: GitHubRelease = await response.json();
const latestVersion = release.tag_name.replace('v', '');
const isUpToDate = currentVersion === latestVersion;
return {
success: true,
currentVersion,
latestVersion,
isUpToDate,
updateAvailable: !isUpToDate,
releaseInfo: {
tagName: release.tag_name,
name: release.name,
publishedAt: release.published_at,
htmlUrl: release.html_url
}
};
} catch (error) {
console.error('Error checking version status:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to check version status',
currentVersion: null,
latestVersion: null,
isUpToDate: false,
updateAvailable: false,
releaseInfo: null
};
}
}),
// Execute update script
executeUpdate: publicProcedure
.mutation(async () => {
try {
const updateScriptPath = join(process.cwd(), 'update.sh');
// Spawn the update script as a detached process using nohup
// This allows it to run independently and kill the parent Node.js process
const child = spawn('nohup', ['bash', updateScriptPath], {
cwd: process.cwd(),
stdio: ['ignore', 'ignore', 'ignore'],
shell: false,
detached: true
});
// Unref the child process so it doesn't keep the parent alive
child.unref();
// Immediately return success since we can't wait for completion
// The script will handle its own logging and restart
return {
success: true,
message: 'Update started in background. The server will restart automatically when complete.',
output: '',
error: ''
};
} catch (error) {
console.error('Error executing update script:', error);
return {
success: false,
message: `Failed to execute update script: ${error instanceof Error ? error.message : 'Unknown error'}`,
output: '',
error: error instanceof Error ? error.message : 'Unknown error'
};
}
})
});

View File

@@ -1,11 +1,130 @@
@import "tailwindcss";
@theme {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
::selection {
background-color: hsl(var(--accent));
color: hsl(var(--foreground));
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@variant dark (&:is(.dark, .dark *));
/* Semantic color utility classes */
.bg-background { background-color: hsl(var(--background)); }
.text-foreground { color: hsl(var(--foreground)); }
.bg-card { background-color: hsl(var(--card)); }
.text-card-foreground { color: hsl(var(--card-foreground)); }
.bg-popover { background-color: hsl(var(--popover)); }
.text-popover-foreground { color: hsl(var(--popover-foreground)); }
.bg-primary { background-color: hsl(var(--primary)); }
.text-primary { color: hsl(var(--primary)); }
.text-primary-foreground { color: hsl(var(--primary-foreground)); }
.bg-secondary { background-color: hsl(var(--secondary)); }
.text-secondary { color: hsl(var(--secondary)); }
.text-secondary-foreground { color: hsl(var(--secondary-foreground)); }
.bg-muted { background-color: hsl(var(--muted)); }
.text-muted-foreground { color: hsl(var(--muted-foreground)); }
.bg-accent { background-color: hsl(var(--accent)); }
.text-accent { color: hsl(var(--accent)); }
.text-accent-foreground { color: hsl(var(--accent-foreground)); }
.bg-destructive { background-color: hsl(var(--destructive)); }
.text-destructive { color: hsl(var(--destructive)); }
.text-destructive-foreground { color: hsl(var(--destructive-foreground)); }
.border-border { border-color: hsl(var(--border)); }
.border-input { border-color: hsl(var(--input)); }
.ring-ring { --tw-ring-color: hsl(var(--ring)); }
/* Hover states for semantic colors */
.hover\:bg-accent:hover { background-color: hsl(var(--accent)); }
.hover\:text-accent-foreground:hover { color: hsl(var(--accent-foreground)); }
.hover\:text-foreground:hover { color: hsl(var(--foreground)); }
.hover\:bg-primary:hover { background-color: hsl(var(--primary)); }
.hover\:bg-primary\/90:hover { background-color: hsl(var(--primary) / 0.9); }
.hover\:bg-secondary:hover { background-color: hsl(var(--secondary)); }
.hover\:bg-secondary\/80:hover { background-color: hsl(var(--secondary) / 0.8); }
.hover\:bg-muted:hover { background-color: hsl(var(--muted)); }
.hover\:text-primary:hover { color: hsl(var(--primary)); }
.hover\:text-primary\/80:hover { color: hsl(var(--primary) / 0.8); }
.hover\:border-primary:hover { border-color: hsl(var(--primary)); }
.hover\:border-border:hover { border-color: hsl(var(--border)); }
.hover\:ring-primary:hover { --tw-ring-color: hsl(var(--primary)); }
.hover\:ring-2:hover { --tw-ring-width: 2px; }
.hover\:ring-offset-2:hover { --tw-ring-offset-width: 2px; }
* {
-ms-overflow-style: none;
}
::-webkit-scrollbar {
width: 9px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.25);
border-radius: 20px;
border: transparent;
}
.glass {
backdrop-filter: blur(15px) saturate(100%);
-webkit-backdrop-filter: blur(15px) saturate(100%);
}
/* Terminal-specific styles for ANSI escape code rendering */
.terminal-output {

View File

@@ -27,7 +27,8 @@
/* Path Aliases */
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
"~/*": ["./src/*"],
"@/*": ["./src/*"]
}
},
"include": [

895
update.sh Executable file
View File

@@ -0,0 +1,895 @@
#!/bin/bash
# Enhanced update script for ProxmoxVE-Local
# Fetches latest release from GitHub and backs up data directory
set -euo pipefail # Exit on error, undefined vars, pipe failures
# Add error trap for debugging
trap 'echo "Error occurred at line $LINENO, command: $BASH_COMMAND"' ERR
# Configuration
REPO_OWNER="community-scripts"
REPO_NAME="ProxmoxVE-Local"
GITHUB_API="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}"
BACKUP_DIR="/tmp/pve-scripts-backup-$(date +%Y%m%d-%H%M%S)"
DATA_DIR="./data"
LOG_FILE="/tmp/update.log"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Initialize log file
init_log() {
# Clear/create log file
> "$LOG_FILE"
log "Starting ProxmoxVE-Local update process..."
log "Log file: $LOG_FILE"
}
# Logging function
log() {
echo -e "${BLUE}[$(date '+%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE" >&2
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE" >&2
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1" | tee -a "$LOG_FILE" >&2
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE" >&2
}
# Check if required tools are available
check_dependencies() {
log "Checking dependencies..."
local missing_deps=()
if ! command -v curl &> /dev/null; then
missing_deps+=("curl")
fi
if ! command -v jq &> /dev/null; then
missing_deps+=("jq")
fi
if ! command -v npm &> /dev/null; then
missing_deps+=("npm")
fi
if ! command -v node &> /dev/null; then
missing_deps+=("node")
fi
if [ ${#missing_deps[@]} -ne 0 ]; then
log_error "Missing dependencies: ${missing_deps[*]}"
log_error "Please install the missing dependencies and try again."
exit 1
fi
log_success "All dependencies are available"
}
# Get latest release info from GitHub API
get_latest_release() {
log "Fetching latest release information from GitHub..."
local release_info
if ! release_info=$(curl -s --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 3 "$GITHUB_API/releases/latest"); then
log_error "Failed to fetch release information from GitHub API (timeout or network error)"
exit 1
fi
# Check if response is valid JSON
if ! echo "$release_info" | jq empty 2>/dev/null; then
log_error "Invalid JSON response from GitHub API"
log "Response: $release_info"
exit 1
fi
local tag_name
local download_url
local published_at
tag_name=$(echo "$release_info" | jq -r '.tag_name')
download_url=$(echo "$release_info" | jq -r '.tarball_url')
published_at=$(echo "$release_info" | jq -r '.published_at')
if [ "$tag_name" = "null" ] || [ "$download_url" = "null" ] || [ -z "$tag_name" ] || [ -z "$download_url" ]; then
log_error "Failed to parse release information from API response"
log "Tag name: $tag_name"
log "Download URL: $download_url"
exit 1
fi
log_success "Latest release: $tag_name (published: $published_at)"
echo "$tag_name|$download_url"
}
# Backup data directory and .env file
backup_data() {
log "Creating backup directory at $BACKUP_DIR..."
if ! mkdir -p "$BACKUP_DIR"; then
log_error "Failed to create backup directory"
exit 1
fi
# Backup data directory
if [ -d "$DATA_DIR" ]; then
log "Backing up data directory..."
if ! cp -r "$DATA_DIR" "$BACKUP_DIR/data"; then
log_error "Failed to backup data directory"
exit 1
else
log_success "Data directory backed up successfully"
fi
else
log_warning "Data directory not found, skipping backup"
fi
# Backup .env file
if [ -f ".env" ]; then
log "Backing up .env file..."
if ! cp ".env" "$BACKUP_DIR/.env"; then
log_error "Failed to backup .env file"
exit 1
else
log_success ".env file backed up successfully"
fi
else
log_warning ".env file not found, skipping backup"
fi
}
# Download and extract latest release
download_release() {
local release_info="$1"
local tag_name="${release_info%|*}"
local download_url="${release_info#*|}"
log "Downloading release $tag_name..."
local temp_dir="/tmp/pve-update-$$"
local archive_file="$temp_dir/release.tar.gz"
# Create temporary directory
if ! mkdir -p "$temp_dir"; then
log_error "Failed to create temporary directory"
exit 1
fi
# Download release with timeout and progress
log "Downloading from: $download_url"
log "Target file: $archive_file"
log "Starting curl download..."
# Test if curl is working
log "Testing curl availability..."
if ! command -v curl >/dev/null 2>&1; then
log_error "curl command not found"
rm -rf "$temp_dir"
exit 1
fi
# Test basic connectivity
log "Testing basic connectivity..."
if ! curl -s --connect-timeout 10 --max-time 30 "https://api.github.com" >/dev/null 2>&1; then
log_error "Cannot reach GitHub API"
rm -rf "$temp_dir"
exit 1
fi
log_success "Connectivity test passed"
# Create a temporary file for curl output
local curl_log="/tmp/curl_log_$$.txt"
# Run curl with verbose output
if curl -L --connect-timeout 30 --max-time 300 --retry 3 --retry-delay 5 -v -o "$archive_file" "$download_url" > "$curl_log" 2>&1; then
log_success "Curl command completed successfully"
# Show some of the curl output for debugging
log "Curl output (first 10 lines):"
head -10 "$curl_log" | while read -r line; do
log "CURL: $line"
done
else
local curl_exit_code=$?
log_error "Curl command failed with exit code: $curl_exit_code"
log_error "Curl output:"
cat "$curl_log" | while read -r line; do
log_error "CURL: $line"
done
rm -f "$curl_log"
rm -rf "$temp_dir"
exit 1
fi
# Clean up curl log
rm -f "$curl_log"
# Verify download
if [ ! -f "$archive_file" ] || [ ! -s "$archive_file" ]; then
log_error "Downloaded file is empty or missing"
rm -rf "$temp_dir"
exit 1
fi
local file_size
file_size=$(stat -c%s "$archive_file" 2>/dev/null || echo "0")
log_success "Downloaded release ($file_size bytes)"
# Extract release
log "Extracting release..."
if ! tar -xzf "$archive_file" -C "$temp_dir"; then
log_error "Failed to extract release"
rm -rf "$temp_dir"
exit 1
fi
# Debug: List contents after extraction
log "Contents after extraction:"
ls -la "$temp_dir" >&2 || true
# Find the extracted directory (GitHub tarballs have a root directory)
log "Looking for extracted directory with pattern: ${REPO_NAME}-*"
local extracted_dir
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "${REPO_NAME}-*" 2>/dev/null | head -1)
# If not found with repo name, try alternative patterns
if [ -z "$extracted_dir" ]; then
log "Trying pattern: community-scripts-ProxmoxVE-Local-*"
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" 2>/dev/null | head -1)
fi
if [ -z "$extracted_dir" ]; then
log "Trying pattern: ProxmoxVE-Local-*"
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "ProxmoxVE-Local-*" 2>/dev/null | head -1)
fi
if [ -z "$extracted_dir" ]; then
log "Trying any directory in temp folder"
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d ! -name "$temp_dir" 2>/dev/null | head -1)
fi
# If still not found, error out
if [ -z "$extracted_dir" ]; then
log_error "Could not find extracted directory"
rm -rf "$temp_dir"
exit 1
fi
log_success "Found extracted directory: $extracted_dir"
log_success "Release downloaded and extracted successfully"
echo "$extracted_dir"
}
# Clear the original directory before updating
clear_original_directory() {
log "Clearing original directory..."
# List of files/directories to preserve (already backed up)
local preserve_patterns=(
"data"
".env"
"*.log"
"update.log"
"*.backup"
"*.bak"
"node_modules"
".git"
)
# Remove all files except preserved ones
while IFS= read -r file; do
local should_preserve=false
local filename=$(basename "$file")
for pattern in "${preserve_patterns[@]}"; do
if [[ "$filename" == $pattern ]]; then
should_preserve=true
break
fi
done
if [ "$should_preserve" = false ]; then
rm -f "$file"
fi
done < <(find . -maxdepth 1 -type f ! -name ".*")
# Remove all directories except preserved ones
while IFS= read -r dir; do
local should_preserve=false
local dirname=$(basename "$dir")
for pattern in "${preserve_patterns[@]}"; do
if [[ "$dirname" == $pattern ]]; then
should_preserve=true
break
fi
done
if [ "$should_preserve" = false ]; then
rm -rf "$dir"
fi
done < <(find . -maxdepth 1 -type d ! -name "." ! -name "..")
log_success "Original directory cleared"
}
# Restore backup files before building
restore_backup_files() {
log "Restoring .env and data directory from backup..."
if [ -d "$BACKUP_DIR" ]; then
# Restore .env file
if [ -f "$BACKUP_DIR/.env" ]; then
if [ -f ".env" ]; then
rm -f ".env"
fi
if mv "$BACKUP_DIR/.env" ".env"; then
log_success ".env file restored from backup"
else
log_error "Failed to restore .env file"
return 1
fi
else
log_warning "No .env file backup found"
fi
# Restore data directory
if [ -d "$BACKUP_DIR/data" ]; then
if [ -d "data" ]; then
rm -rf "data"
fi
if mv "$BACKUP_DIR/data" "data"; then
log_success "Data directory restored from backup"
else
log_error "Failed to restore data directory"
return 1
fi
else
log_warning "No data directory backup found"
fi
else
log_error "No backup directory found for restoration"
return 1
fi
}
# Check if systemd service exists
check_service() {
if systemctl list-unit-files | grep -q "^pvescriptslocal.service"; then
return 0
else
return 1
fi
}
# Kill application processes directly
kill_processes() {
# Try to find and stop the Node.js process
local pids
pids=$(pgrep -f "node server.js" 2>/dev/null || true)
# Also check for npm start processes
local npm_pids
npm_pids=$(pgrep -f "npm start" 2>/dev/null || true)
# Combine all PIDs
if [ -n "$npm_pids" ]; then
pids="$pids $npm_pids"
fi
if [ -n "$pids" ]; then
log "Stopping application processes: $pids"
# Send TERM signal to each PID individually
for pid in $pids; do
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
log "Sending TERM signal to PID: $pid"
kill -TERM "$pid" 2>/dev/null || true
fi
done
# Wait for graceful shutdown with timeout
log "Waiting for graceful shutdown..."
local wait_count=0
local max_wait=10 # Maximum 10 seconds
while [ $wait_count -lt $max_wait ]; do
local still_running
still_running=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -z "$still_running" ]; then
log_success "Processes stopped gracefully"
break
fi
sleep 1
wait_count=$((wait_count + 1))
log "Waiting... ($wait_count/$max_wait)"
done
# Force kill any remaining processes
local remaining_pids
remaining_pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$remaining_pids" ]; then
log_warning "Force killing remaining processes: $remaining_pids"
pkill -9 -f "node server.js" 2>/dev/null || true
pkill -9 -f "npm start" 2>/dev/null || true
sleep 1
fi
# Final check
local final_check
final_check=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$final_check" ]; then
log_warning "Some processes may still be running: $final_check"
else
log_success "All application processes stopped"
fi
else
log "No running application processes found"
fi
}
# Kill application processes directly
kill_processes() {
# Try to find and stop the Node.js process
local pids
pids=$(pgrep -f "node server.js" 2>/dev/null || true)
# Also check for npm start processes
local npm_pids
npm_pids=$(pgrep -f "npm start" 2>/dev/null || true)
# Combine all PIDs
if [ -n "$npm_pids" ]; then
pids="$pids $npm_pids"
fi
if [ -n "$pids" ]; then
log "Stopping application processes: $pids"
# Send TERM signal to each PID individually
for pid in $pids; do
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
log "Sending TERM signal to PID: $pid"
kill -TERM "$pid" 2>/dev/null || true
fi
done
# Wait for graceful shutdown with timeout
log "Waiting for graceful shutdown..."
local wait_count=0
local max_wait=10 # Maximum 10 seconds
while [ $wait_count -lt $max_wait ]; do
local still_running
still_running=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -z "$still_running" ]; then
log_success "Processes stopped gracefully"
break
fi
sleep 1
wait_count=$((wait_count + 1))
log "Waiting... ($wait_count/$max_wait)"
done
# Force kill any remaining processes
local remaining_pids
remaining_pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$remaining_pids" ]; then
log_warning "Force killing remaining processes: $remaining_pids"
pkill -9 -f "node server.js" 2>/dev/null || true
pkill -9 -f "npm start" 2>/dev/null || true
sleep 1
fi
# Final check
local final_check
final_check=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$final_check" ]; then
log_warning "Some processes may still be running: $final_check"
else
log_success "All application processes stopped"
fi
else
log "No running application processes found"
fi
}
# Stop the application before updating
stop_application() {
log "Stopping application..."
# Change to the application directory if we're not already there
local app_dir
if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)"
else
# Try to find the application directory
app_dir=$(find /root -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
cd "$app_dir" || {
log_error "Failed to change to application directory: $app_dir"
return 1
}
else
log_error "Could not find application directory"
return 1
fi
fi
log "Working from application directory: $(pwd)"
# Check if systemd service exists and is active
if check_service; then
if systemctl is-active --quiet pvescriptslocal.service; then
log "Stopping pvescriptslocal service..."
if systemctl stop pvescriptslocal.service; then
log_success "Service stopped successfully"
else
log_error "Failed to stop service, falling back to process kill"
kill_processes
fi
else
log "Service exists but is not active, checking for running processes..."
kill_processes
fi
else
log "No systemd service found, stopping processes directly..."
kill_processes
fi
}
# Update application files
update_files() {
local source_dir="$1"
log "Updating application files..."
# List of files/directories to exclude from update
local exclude_patterns=(
"data"
"node_modules"
".git"
".env"
"*.log"
"update.log"
"*.backup"
"*.bak"
)
# Find the actual source directory (strip the top-level directory)
local actual_source_dir
actual_source_dir=$(find "$source_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" | head -1)
if [ -z "$actual_source_dir" ]; then
log_error "Could not find the actual source directory in $source_dir"
return 1
fi
# Use process substitution instead of pipe to avoid subshell issues
local files_copied=0
local files_excluded=0
log "Starting file copy process from: $actual_source_dir"
# Create a temporary file list to avoid process substitution issues
local file_list="/tmp/file_list_$$.txt"
find "$actual_source_dir" -type f > "$file_list"
local total_files
total_files=$(wc -l < "$file_list")
log "Found $total_files files to process"
# Show first few files for debugging
log "First few files to process:"
head -5 "$file_list" | while read -r f; do
log " - $f"
done
while IFS= read -r file; do
local rel_path="${file#$actual_source_dir/}"
local should_exclude=false
for pattern in "${exclude_patterns[@]}"; do
if [[ "$rel_path" == $pattern ]]; then
should_exclude=true
break
fi
done
if [ "$should_exclude" = false ]; then
local target_dir
target_dir=$(dirname "$rel_path")
if [ "$target_dir" != "." ]; then
mkdir -p "$target_dir"
fi
log "Copying: $file -> $rel_path"
if ! cp "$file" "$rel_path"; then
log_error "Failed to copy $rel_path"
rm -f "$file_list"
return 1
else
files_copied=$((files_copied + 1))
if [ $((files_copied % 10)) -eq 0 ]; then
log "Copied $files_copied files so far..."
fi
fi
else
files_excluded=$((files_excluded + 1))
log "Excluded: $rel_path"
fi
done < "$file_list"
# Clean up temporary file
rm -f "$file_list"
log "Files processed: $files_copied copied, $files_excluded excluded"
log_success "Application files updated successfully"
}
# Install dependencies and build
install_and_build() {
log "Installing dependencies..."
if ! npm install; then
log_error "Failed to install dependencies"
return 1
fi
# Ensure no processes are running before build
log "Ensuring no conflicting processes are running..."
local pids
pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$pids" ]; then
log_warning "Found running processes, stopping them: $pids"
pkill -9 -f "node server.js" 2>/dev/null || true
pkill -9 -f "npm start" 2>/dev/null || true
sleep 2
fi
log "Building application..."
# Set NODE_ENV to production for build
export NODE_ENV=production
if ! npm run build; then
log_error "Failed to build application"
return 1
fi
log_success "Dependencies installed and application built successfully"
}
# Start the application after updating
start_application() {
log "Starting application..."
# Check if systemd service exists
if check_service; then
log "Starting pvescriptslocal service..."
if systemctl start pvescriptslocal.service; then
log_success "Service started successfully"
# Wait a moment and check if it's running
sleep 2
if systemctl is-active --quiet pvescriptslocal.service; then
log_success "Service is running"
else
log_warning "Service started but may not be running properly"
fi
else
log_error "Failed to start service, falling back to npm start"
start_with_npm
fi
else
log "No systemd service found, starting with npm..."
start_with_npm
fi
}
# Start application with npm
start_with_npm() {
log "Starting application with npm start..."
# Start in background
nohup npm start > server.log 2>&1 &
local npm_pid=$!
# Wait a moment and check if it started
sleep 3
if kill -0 $npm_pid 2>/dev/null; then
log_success "Application started with PID: $npm_pid"
else
log_error "Failed to start application with npm"
return 1
fi
}
# Rollback function
rollback() {
log_warning "Rolling back to previous version..."
if [ -d "$BACKUP_DIR" ]; then
log "Restoring from backup directory: $BACKUP_DIR"
# Restore data directory
if [ -d "$BACKUP_DIR/data" ]; then
log "Restoring data directory..."
if [ -d "$DATA_DIR" ]; then
rm -rf "$DATA_DIR"
fi
if mv "$BACKUP_DIR/data" "$DATA_DIR"; then
log_success "Data directory restored from backup"
else
log_error "Failed to restore data directory"
fi
else
log_warning "No data directory backup found"
fi
# Restore .env file
if [ -f "$BACKUP_DIR/.env" ]; then
log "Restoring .env file..."
if [ -f ".env" ]; then
rm -f ".env"
fi
if mv "$BACKUP_DIR/.env" ".env"; then
log_success ".env file restored from backup"
else
log_error "Failed to restore .env file"
fi
else
log_warning "No .env file backup found"
fi
# Clean up backup directory
log "Cleaning up backup directory..."
rm -rf "$BACKUP_DIR"
else
log_error "No backup directory found for rollback"
fi
log_error "Update failed. Please check the logs and try again."
exit 1
}
# Main update process
main() {
init_log
# Check if we're running from the application directory and not already relocated
if [ -z "${PVE_UPDATE_RELOCATED:-}" ] && [ -f "package.json" ] && [ -f "server.js" ]; then
log "Detected running from application directory"
log "Copying update script to temporary location for safe execution..."
local temp_script="/tmp/pve-scripts-update-$$.sh"
if ! cp "$0" "$temp_script"; then
log_error "Failed to copy update script to temporary location"
exit 1
fi
chmod +x "$temp_script"
log "Executing update from temporary location: $temp_script"
# Set flag to prevent infinite loop and execute from temporary location
export PVE_UPDATE_RELOCATED=1
exec "$temp_script" "$@"
fi
# Ensure we're in the application directory
local app_dir
# First check if we're already in the right directory
if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)"
log "Already in application directory: $app_dir"
else
# Try multiple common locations
for search_path in /opt /root /home /usr/local; do
if [ -d "$search_path" ]; then
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
break
fi
fi
done
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
cd "$app_dir" || {
log_error "Failed to change to application directory: $app_dir"
exit 1
}
log "Changed to application directory: $(pwd)"
else
log_error "Could not find application directory"
log "Searched in: /opt, /root, /home, /usr/local"
exit 1
fi
fi
# Check dependencies
check_dependencies
# Get latest release info
local release_info
release_info=$(get_latest_release)
# Backup data directory
backup_data
# Stop the application before updating (now running from /tmp/)
stop_application
# Double-check that no processes are running
local remaining_pids
remaining_pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$remaining_pids" ]; then
log_warning "Force killing remaining processes"
pkill -9 -f "node server.js" 2>/dev/null || true
pkill -9 -f "npm start" 2>/dev/null || true
sleep 2
fi
# Download and extract release
local source_dir
source_dir=$(download_release "$release_info")
log "Download completed, source_dir: $source_dir"
# Clear the original directory before updating
log "Clearing original directory..."
clear_original_directory
log "Original directory cleared successfully"
# Update files
log "Starting file update process..."
if ! update_files "$source_dir"; then
log_error "File update failed, rolling back..."
rollback
fi
log "File update completed successfully"
# Restore .env and data directory before building
log "Restoring backup files..."
restore_backup_files
log "Backup files restored successfully"
# Install dependencies and build
log "Starting install and build process..."
if ! install_and_build; then
log_error "Install and build failed, rolling back..."
rollback
fi
log "Install and build completed successfully"
# Cleanup
log "Cleaning up temporary files..."
rm -rf "$source_dir"
rm -rf "/tmp/pve-update-$$"
# Clean up temporary script if it exists
if [ -f "/tmp/pve-scripts-update-$$.sh" ]; then
rm -f "/tmp/pve-scripts-update-$$.sh"
fi
# Start the application
start_application
log_success "Update completed successfully!"
}
# Run main function with error handling
if ! main "$@"; then
log_error "Update script failed with exit code $?"
exit 1
fi