Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cad71b878 | ||
|
|
9649f63474 | ||
|
|
e63958e5eb | ||
|
|
ba5730287f | ||
|
|
4faa74b4c5 | ||
|
|
aa9e155b0c | ||
|
|
d819cd79fe | ||
|
|
c618fef2ef | ||
|
|
6265ffeab5 | ||
|
|
608a7ac78c | ||
|
|
ff1ab35b46 | ||
|
|
e8be9e7214 | ||
|
|
cfcd09611e | ||
|
|
4ed3e42148 | ||
|
|
a09f331d5f | ||
|
|
36beb427c0 | ||
|
|
ca2cbd5a7f | ||
|
|
d6803b99a6 | ||
|
|
8b630c9201 | ||
|
|
5eaafbde48 | ||
|
|
92f78c7008 | ||
|
|
d932f5a499 | ||
|
|
39a572a393 | ||
|
|
81fbd440ce | ||
|
|
6a84da5e85 | ||
|
|
0d40ced2f8 | ||
|
|
37d7aea258 | ||
|
|
e3f10b8b6e | ||
|
|
6c2868f8b9 | ||
|
|
c2705430a3 | ||
|
|
fc4c6efa8c | ||
|
|
8039d5aa96 | ||
|
|
b670c4e3c8 | ||
|
|
3e90369682 | ||
|
|
24430ee77d | ||
|
|
0b1ce29b64 | ||
|
|
c7af2eb1a8 | ||
|
|
7ff4d56753 | ||
|
|
b2ae96dcd0 | ||
|
|
3530d78c78 | ||
|
|
a3f062a77f | ||
|
|
bcdae46867 | ||
|
|
f055be1f4a | ||
|
|
7e91c598ae | ||
|
|
123977d0a3 | ||
|
|
35cc000a2a | ||
|
|
d71e8dd96a | ||
|
|
67b63019ab | ||
|
|
ff076a5a40 | ||
|
|
a7479091dc | ||
|
|
ff9a875561 | ||
|
|
6bcf139493 | ||
|
|
eb8801bfc8 | ||
|
|
91cdc557a1 | ||
|
|
6a7a1f94f9 | ||
|
|
b96b5493f3 | ||
|
|
6baef6bb84 | ||
|
|
05d88eb8c8 | ||
|
|
03d871eca8 | ||
|
|
b366a33f07 |
10
.env.example
10
.env.example
@@ -16,3 +16,13 @@ ALLOWED_SCRIPT_PATHS="scripts/"
|
||||
|
||||
# WebSocket Configuration
|
||||
WEBSOCKET_PORT="3001"
|
||||
|
||||
# User settings
|
||||
GITHUB_TOKEN=
|
||||
SAVE_FILTER=false
|
||||
FILTERS=
|
||||
AUTH_USERNAME=
|
||||
AUTH_PASSWORD_HASH=
|
||||
AUTH_ENABLED=false
|
||||
AUTH_SETUP_COMPLETED=false
|
||||
JWT_SECRET=
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -11,5 +11,6 @@
|
||||
|
||||
|
||||
# Set default reviewers
|
||||
* @michelroegl-brunner
|
||||
* @community-scripts/Contributor
|
||||
|
||||
|
||||
10
.github/release-drafter.yml
vendored
10
.github/release-drafter.yml
vendored
@@ -1,6 +1,11 @@
|
||||
# Template for release drafts
|
||||
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
||||
tag-template: 'v$NEXT_PATCH_VERSION'
|
||||
name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
||||
tag-template: 'v$NEXT_MINOR_VERSION'
|
||||
|
||||
# Exclude PRs with this label from release notes
|
||||
exclude-labels:
|
||||
- automated
|
||||
|
||||
categories:
|
||||
- title: "🚀 Features"
|
||||
labels:
|
||||
@@ -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
119
.github/workflows/publish_release.yml
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
name: Publish draft release
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger; can be automated later
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get latest draft release
|
||||
id: draft
|
||||
run: |
|
||||
draft_info=$(gh release list --limit 5 --json tagName,isDraft --jq '.[] | select(.isDraft==true) | .tagName' | head -n1)
|
||||
echo "tag_name=${draft_info}" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Validate draft version
|
||||
run: |
|
||||
if [ -z "${{ steps.draft.outputs.tag_name }}" ]; then
|
||||
echo "No draft release found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Found draft version: ${{ steps.draft.outputs.tag_name }}"
|
||||
|
||||
|
||||
- name: Create branch and commit VERSION
|
||||
run: |
|
||||
branch="update-version-${{ steps.draft.outputs.tag_name }}"
|
||||
# Delete remote branch if exists
|
||||
git push origin --delete "$branch" || echo "No remote branch to delete"
|
||||
git fetch origin main
|
||||
git checkout -b "$branch" origin/main
|
||||
# Write VERSION file and timestamp to ensure a diff
|
||||
version="${{ steps.draft.outputs.tag_name }}"
|
||||
echo "$version" | sed 's/^v//' > VERSION
|
||||
git add VERSION
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "chore: add VERSION $version" --allow-empty
|
||||
git push --set-upstream origin "$branch"
|
||||
|
||||
|
||||
- name: Create PR with GitHub CLI
|
||||
id: pr
|
||||
run: |
|
||||
pr_url=$(gh pr create \
|
||||
--base main \
|
||||
--head update-version-${{ steps.draft.outputs.tag_name }} \
|
||||
--title "chore: add VERSION ${{ steps.draft.outputs.tag_name }}" \
|
||||
--body "Adds VERSION file for release ${{ steps.draft.outputs.tag_name }}" \
|
||||
--label automated)
|
||||
|
||||
pr_number=$(echo "$pr_url" | awk -F/ '{print $NF}')
|
||||
echo $pr_number
|
||||
echo "pr_number=$pr_number" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
# - name: Approve pull request
|
||||
# env:
|
||||
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# run: |
|
||||
# PR_NUMBER="${{ steps.pr.outputs.pr_number }}"
|
||||
# if [ -n "$PR_NUMBER" ]; then
|
||||
# gh pr review $PR_NUMBER --approve
|
||||
# fi
|
||||
|
||||
- name: Merge PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions-automege[bot]"
|
||||
git config --global user.email "github-actions-automege[bot]@users.noreply.github.com"
|
||||
PR_NUMBER="${{ steps.pr.outputs.pr_number }}"
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
gh pr merge "$PR_NUMBER" --squash --admin
|
||||
fi
|
||||
|
||||
- name: Wait for PR merge
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prNum = parseInt("${{ steps.pr.outputs.pr_number }}")
|
||||
let merged = false
|
||||
const maxRetries = 20
|
||||
let tries = 0
|
||||
while(!merged && tries < maxRetries){
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNum
|
||||
})
|
||||
merged = pr.data.merged
|
||||
if(!merged){
|
||||
tries++
|
||||
console.log("Waiting for PR to merge...")
|
||||
await new Promise(r => setTimeout(r, 5000))
|
||||
}
|
||||
}
|
||||
if(!merged) throw new Error("PR not merged in time")
|
||||
|
||||
- name: Create tag
|
||||
run: |
|
||||
git tag "${{ steps.draft.outputs.tag_name }}"
|
||||
git push origin "${{ steps.draft.outputs.tag_name }}"
|
||||
|
||||
- name: Publish draft release
|
||||
run: gh release edit "${{ steps.draft.outputs.tag_name }}" --draft=false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -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:
|
||||
|
||||
@@ -18,6 +18,40 @@ const config = {
|
||||
},
|
||||
],
|
||||
},
|
||||
// Allow cross-origin requests from local network ranges
|
||||
allowedDevOrigins: [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://[::1]:3000',
|
||||
'http://10.*',
|
||||
'http://172.16.*',
|
||||
'http://172.17.*',
|
||||
'http://172.18.*',
|
||||
'http://172.19.*',
|
||||
'http://172.20.*',
|
||||
'http://172.21.*',
|
||||
'http://172.22.*',
|
||||
'http://172.23.*',
|
||||
'http://172.24.*',
|
||||
'http://172.25.*',
|
||||
'http://172.26.*',
|
||||
'http://172.27.*',
|
||||
'http://172.28.*',
|
||||
'http://172.29.*',
|
||||
'http://172.30.*',
|
||||
'http://172.31.*',
|
||||
'http://192.168.*',
|
||||
],
|
||||
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
if (dev && !isServer) {
|
||||
config.watchOptions = {
|
||||
poll: 1000,
|
||||
aggregateTimeout: 300,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
1895
package-lock.json
generated
1895
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@@ -22,17 +22,23 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@tanstack/react-query": "^5.87.4",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next": "^15.5.3",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -42,29 +48,32 @@
|
||||
"server-only": "^0.0.1",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.7.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-config-next": "^15.2.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"eslint-config-next": "^15.5.4",
|
||||
"jsdom": "^27.0.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.27.0",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
@@ -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",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"documentation": "https://www.zigbee2mqtt.io/guide/getting-started/",
|
||||
"website": "https://www.zigbee2mqtt.io/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/zigbee2mqtt.webp",
|
||||
"config_path": "/opt/zigbee2mqtt/data/configuration.yaml",
|
||||
"config_path": "debian: /opt/zigbee2mqtt/data/configuration.yaml | alpine: /var/lib/zigbee2mqtt/configuration.yaml",
|
||||
"description": "Zigbee2MQTT is an open-source software project that allows you to use Zigbee-based smart home devices (such as those sold under the Philips Hue and Ikea Tradfri brands) with MQTT-based home automation systems, like Home Assistant, Node-RED, and others. The software acts as a bridge between your Zigbee devices and MQTT, allowing you to control and monitor these devices from your home automation system.",
|
||||
"install_methods": [
|
||||
{
|
||||
|
||||
29
server.log
29
server.log
@@ -1,29 +0,0 @@
|
||||
|
||||
> pve-scripts-local@0.1.0 dev
|
||||
> node server.js
|
||||
|
||||
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
||||
code: 'EADDRINUSE',
|
||||
errno: -98,
|
||||
syscall: 'listen',
|
||||
address: '0.0.0.0',
|
||||
port: 3000
|
||||
}
|
||||
⨯ uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
||||
code: 'EADDRINUSE',
|
||||
errno: -98,
|
||||
syscall: 'listen',
|
||||
address: '0.0.0.0',
|
||||
port: 3000
|
||||
}
|
||||
⨯ uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
||||
code: 'EADDRINUSE',
|
||||
errno: -98,
|
||||
syscall: 'listen',
|
||||
address: '0.0.0.0',
|
||||
port: 3000
|
||||
}
|
||||
Terminated
|
||||
73
src/app/_components/AuthGuard.tsx
Normal file
73
src/app/_components/AuthGuard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import { AuthModal } from './AuthModal';
|
||||
import { SetupModal } from './SetupModal';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface AuthConfig {
|
||||
username: string | null;
|
||||
enabled: boolean;
|
||||
hasCredentials: boolean;
|
||||
setupCompleted: boolean;
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
|
||||
const [configLoading, setConfigLoading] = useState(true);
|
||||
const [setupCompleted, setSetupCompleted] = useState(false);
|
||||
|
||||
const handleSetupComplete = async () => {
|
||||
setSetupCompleted(true);
|
||||
// Refresh auth config without reloading the page
|
||||
await fetchAuthConfig();
|
||||
};
|
||||
|
||||
const fetchAuthConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/auth-credentials');
|
||||
if (response.ok) {
|
||||
const config = await response.json() as AuthConfig;
|
||||
setAuthConfig(config);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching auth config:', error);
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchAuthConfig();
|
||||
}, []);
|
||||
|
||||
// Show loading while checking auth status
|
||||
if (isLoading || configLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show setup modal if setup has not been completed yet
|
||||
if (authConfig && !authConfig.setupCompleted && !setupCompleted) {
|
||||
return <SetupModal isOpen={true} onComplete={handleSetupComplete} />;
|
||||
}
|
||||
|
||||
// Show auth modal if auth is enabled but user is not authenticated
|
||||
if (authConfig && authConfig.enabled && !isAuthenticated) {
|
||||
return <AuthModal isOpen={true} />;
|
||||
}
|
||||
|
||||
// Render children if authenticated or auth is disabled
|
||||
return <>{children}</>;
|
||||
}
|
||||
111
src/app/_components/AuthModal.tsx
Normal file
111
src/app/_components/AuthModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import { Lock, User, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function AuthModal({ isOpen }: AuthModalProps) {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const success = await login(username, password);
|
||||
|
||||
if (!success) {
|
||||
setError('Invalid username or password');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Lock className="h-8 w-8 text-blue-600" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Authentication Required</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
Please enter your credentials to access the application.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-800 border border-red-200 rounded-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !username.trim() || !password.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? 'Signing In...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/app/_components/AuthProvider.tsx
Normal file
119
src/app/_components/AuthProvider.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
username: string | null;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// First check if setup is completed
|
||||
const setupResponse = await fetch('/api/settings/auth-credentials');
|
||||
if (setupResponse.ok) {
|
||||
const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
|
||||
|
||||
// If setup is not completed or auth is disabled, don't verify
|
||||
if (!setupData.setupCompleted || !setupData.enabled) {
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only verify authentication if setup is completed and auth is enabled
|
||||
const response = await fetch('/api/auth/verify');
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { username: string };
|
||||
setIsAuthenticated(true);
|
||||
setUsername(data.username);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking auth:', error);
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { username: string };
|
||||
setIsAuthenticated(true);
|
||||
setUsername(data.username);
|
||||
return true;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error('Login failed:', errorData.error);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
// Clear the auth cookie by setting it to expire
|
||||
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void checkAuth();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isAuthenticated,
|
||||
username,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
checkAuth,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -16,15 +16,15 @@ export function Badge({ variant, type, noteType, status, executionMode, children
|
||||
const getTypeStyles = (scriptType: string) => {
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; c
|
||||
),
|
||||
key: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z" />
|
||||
</svg>
|
||||
),
|
||||
archive: (
|
||||
@@ -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 ${
|
||||
isCollapsed ? 'w-16' : 'w-80'
|
||||
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
|
||||
isCollapsed ? 'w-16' : 'w-full lg:w-80'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-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"
|
||||
@@ -233,21 +233,21 @@ export function CategorySidebar({
|
||||
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'
|
||||
? '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>
|
||||
@@ -292,32 +292,32 @@ export function CategorySidebar({
|
||||
|
||||
{/* Collapsed state - show only icons with counters and tooltips */}
|
||||
{isCollapsed && (
|
||||
<div className="p-2 flex flex-col space-y-2">
|
||||
<div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
|
||||
{/* "All Categories" option */}
|
||||
<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'
|
||||
? '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 hidden lg:block">
|
||||
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 hidden lg:block">
|
||||
{category} ({count})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
125
src/app/_components/ColorCodedDropdown.tsx
Normal file
125
src/app/_components/ColorCodedDropdown.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { Server } from '../../types/server';
|
||||
|
||||
interface ColorCodedDropdownProps {
|
||||
servers: Server[];
|
||||
selectedServer: Server | null;
|
||||
onServerSelect: (server: Server | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ColorCodedDropdown({
|
||||
servers,
|
||||
selectedServer,
|
||||
onServerSelect,
|
||||
placeholder = "Select a server...",
|
||||
disabled = false
|
||||
}: ColorCodedDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleServerClick = (server: Server) => {
|
||||
onServerSelect(server);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
onServerSelect(null);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Dropdown Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary bg-background text-foreground text-left flex items-center justify-between ${
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedServer ? (
|
||||
<span className="flex items-center gap-2">
|
||||
{selectedServer.color && (
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: selectedServer.color }}
|
||||
/>
|
||||
)}
|
||||
{selectedServer.name} ({selectedServer.ip}) - {selectedServer.user}
|
||||
</span>
|
||||
) : (
|
||||
placeholder
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-card border border-border rounded-md shadow-lg max-h-60 overflow-auto">
|
||||
{/* Clear Selection Option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSelection}
|
||||
className="w-full px-3 py-2 text-left text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
{placeholder}
|
||||
</button>
|
||||
|
||||
{/* Server Options */}
|
||||
{servers
|
||||
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
||||
.map((server) => (
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
onClick={() => handleServerClick(server)}
|
||||
className={`w-full px-3 py-2 text-left text-sm transition-colors flex items-center gap-2 ${
|
||||
selectedServer?.id === server.id
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{server.color && (
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: server.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{server.name} ({server.ip}) - {server.user}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface DarkModeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
|
||||
|
||||
export function DarkModeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>('system');
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Initialize theme from localStorage after mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme;
|
||||
if (stored && ['light', 'dark', 'system'].includes(stored)) {
|
||||
setThemeState(stored);
|
||||
}
|
||||
|
||||
// Set initial isDark state based on current DOM state
|
||||
const currentlyDark = document.documentElement.classList.contains('dark');
|
||||
setIsDark(currentlyDark);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Update dark mode state and DOM when theme changes
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const updateDarkMode = () => {
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const shouldBeDark = theme === 'dark' || (theme === 'system' && systemDark);
|
||||
|
||||
// Only update if there's actually a change
|
||||
if (shouldBeDark !== isDark) {
|
||||
setIsDark(shouldBeDark);
|
||||
|
||||
// Apply to document
|
||||
if (shouldBeDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateDarkMode();
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
if (theme === 'system') {
|
||||
updateDarkMode();
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme, mounted, isDark]);
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<DarkModeContext.Provider value={{ theme, setTheme, isDark }}>
|
||||
{children}
|
||||
</DarkModeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDarkMode() {
|
||||
const context = useContext(DarkModeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useDarkMode must be used within a DarkModeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useDarkMode } from './DarkModeProvider';
|
||||
|
||||
export function DarkModeToggle() {
|
||||
const { theme, setTheme, isDark } = useDarkMode();
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === 'light') {
|
||||
setTheme('dark');
|
||||
} else if (theme === 'dark') {
|
||||
setTheme('system');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
if (theme === 'light') {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
} else if (theme === 'dark') {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
);
|
||||
} else {
|
||||
// System theme icon
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7l.159-1.591A3.001 3.001 0 0112 8a3 3 0 01-3.229 2.409L8.771 12z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = () => {
|
||||
if (theme === 'light') return 'Light mode';
|
||||
if (theme === 'dark') return 'Dark mode';
|
||||
return 'System theme';
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`
|
||||
flex items-center justify-center
|
||||
w-10 h-10 rounded-lg
|
||||
transition-all duration-200
|
||||
hover:scale-105 active:scale-95
|
||||
${isDark
|
||||
? 'bg-gray-800 text-yellow-400 hover:bg-gray-700 border border-gray-600'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-300'
|
||||
}
|
||||
`}
|
||||
title={getLabel()}
|
||||
aria-label={getLabel()}
|
||||
>
|
||||
{getIcon()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -45,17 +45,17 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
key={index}
|
||||
className={`flex font-mono text-sm ${
|
||||
isAdded
|
||||
? 'bg-green-50 text-green-800 border-l-4 border-green-400'
|
||||
? 'bg-green-500/10 text-green-400 border-l-4 border-green-500'
|
||||
: isRemoved
|
||||
? 'bg-red-50 text-red-800 border-l-4 border-red-400'
|
||||
: 'bg-gray-50 text-gray-700'
|
||||
? 'bg-destructive/10 text-destructive border-l-4 border-destructive'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 text-right pr-2 text-gray-500 select-none">
|
||||
<div className="w-16 text-right pr-2 text-muted-foreground select-none">
|
||||
{lineNumber}
|
||||
</div>
|
||||
<div className="flex-1 pl-2">
|
||||
<span className={isAdded ? 'text-green-600' : isRemoved ? 'text-red-600' : ''}>
|
||||
<span className={isAdded ? 'text-green-400' : isRemoved ? 'text-destructive' : ''}>
|
||||
{isAdded ? '+' : isRemoved ? '-' : ' '}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap">{content}</span>
|
||||
@@ -66,27 +66,27 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden border border-border mx-4 sm:mx-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Script Diff</h2>
|
||||
<p className="text-sm text-gray-600">{filePath}</p>
|
||||
<h2 className="text-xl font-bold text-foreground">Script Diff</h2>
|
||||
<p className="text-sm text-muted-foreground">{filePath}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors disabled:opacity-50"
|
||||
className="px-3 py-1 text-sm bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -96,19 +96,19 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="px-4 py-2 bg-gray-50 border-b border-gray-200">
|
||||
<div className="px-4 py-2 bg-muted border-b border-border">
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-green-100 border border-green-300"></div>
|
||||
<span className="text-green-700">Added (Remote)</span>
|
||||
<div className="w-3 h-3 bg-green-500/20 border border-green-500/40"></div>
|
||||
<span className="text-green-400">Added (Remote)</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-red-100 border border-red-300"></div>
|
||||
<span className="text-red-700">Removed (Local)</span>
|
||||
<div className="w-3 h-3 bg-destructive/20 border border-destructive/40"></div>
|
||||
<span className="text-destructive">Removed (Local)</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-gray-100 border border-gray-300"></div>
|
||||
<span className="text-gray-700">Unchanged</span>
|
||||
<div className="w-3 h-3 bg-muted border border-border"></div>
|
||||
<span className="text-muted-foreground">Unchanged</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,14 +117,14 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
{diffData?.success ? (
|
||||
diffData.diff ? (
|
||||
<div className="divide-y divide-gray-200">
|
||||
<div className="divide-y divide-border">
|
||||
{diffData.diff.split('\n').map((line, index) =>
|
||||
line.trim() ? renderDiffLine(line, index) : null
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>No differences found</p>
|
||||
@@ -132,16 +132,16 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
</div>
|
||||
)
|
||||
) : diffData?.error ? (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="p-8 text-center text-destructive">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>Error loading diff</p>
|
||||
<p className="text-sm">{diffData.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p>Loading diff...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
527
src/app/_components/DownloadedScriptsTab.tsx
Normal file
527
src/app/_components/DownloadedScriptsTab.tsx
Normal file
@@ -0,0 +1,527 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { ScriptCard } from './ScriptCard';
|
||||
import { ScriptCardList } from './ScriptCardList';
|
||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||
import { CategorySidebar } from './CategorySidebar';
|
||||
import { FilterBar, type FilterState } from './FilterBar';
|
||||
import { ViewToggle } from './ViewToggle';
|
||||
import { Button } from './ui/button';
|
||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||
|
||||
interface DownloadedScriptsTabProps {
|
||||
onInstallScript?: (
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
mode?: "local" | "ssh",
|
||||
server?: any,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
searchQuery: '',
|
||||
showUpdatable: null,
|
||||
selectedTypes: [],
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getCtScripts.useQuery();
|
||||
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||
{ slug: selectedSlug ?? '' },
|
||||
{ enabled: !!selectedSlug }
|
||||
);
|
||||
|
||||
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Load SAVE_FILTER setting
|
||||
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||
let saveFilterEnabled = false;
|
||||
if (saveFilterResponse.ok) {
|
||||
const saveFilterData = await saveFilterResponse.json();
|
||||
saveFilterEnabled = saveFilterData.enabled ?? false;
|
||||
setSaveFiltersEnabled(saveFilterEnabled);
|
||||
}
|
||||
|
||||
// Load saved filters if SAVE_FILTER is enabled
|
||||
if (saveFilterEnabled) {
|
||||
const filtersResponse = await fetch('/api/settings/filters');
|
||||
if (filtersResponse.ok) {
|
||||
const filtersData = await filtersResponse.json();
|
||||
if (filtersData.filters) {
|
||||
setFilters(filtersData.filters as FilterState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load view mode
|
||||
const viewModeResponse = await fetch('/api/settings/view-mode');
|
||||
if (viewModeResponse.ok) {
|
||||
const viewModeData = await viewModeResponse.json();
|
||||
const viewMode = viewModeData.viewMode;
|
||||
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
|
||||
setViewMode(viewMode);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
} finally {
|
||||
setIsLoadingFilters(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadSettings();
|
||||
}, []);
|
||||
|
||||
// Save filters when they change (if SAVE_FILTER is enabled)
|
||||
useEffect(() => {
|
||||
if (!saveFiltersEnabled || isLoadingFilters) return;
|
||||
|
||||
const saveFilters = async () => {
|
||||
try {
|
||||
await fetch('/api/settings/filters', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filters }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving filters:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce the save operation
|
||||
const timeoutId = setTimeout(() => void saveFilters(), 500);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||
|
||||
// Save view mode when it changes
|
||||
useEffect(() => {
|
||||
if (isLoadingFilters) return;
|
||||
|
||||
const saveViewMode = async () => {
|
||||
try {
|
||||
await fetch('/api/settings/view-mode', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ viewMode }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving view mode:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce the save operation
|
||||
const timeoutId = setTimeout(() => void saveViewMode(), 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [viewMode, isLoadingFilters]);
|
||||
|
||||
// Extract categories from metadata
|
||||
const categories = React.useMemo((): string[] => {
|
||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||
|
||||
return (scriptCardsData.metadata.categories as any[])
|
||||
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((cat) => cat.name as string)
|
||||
.filter((name): name is string => typeof name === 'string');
|
||||
}, [scriptCardsData]);
|
||||
|
||||
// Get GitHub scripts with download status (deduplicated)
|
||||
const combinedScripts = React.useMemo((): ScriptCardType[] => {
|
||||
if (!scriptCardsData?.success) return [];
|
||||
|
||||
// Use Map to deduplicate by slug/name
|
||||
const scriptMap = new Map<string, ScriptCardType>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
if (script?.name && script?.slug) {
|
||||
// Use slug as unique identifier, only keep first occurrence
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
scriptMap.set(script.slug, {
|
||||
...script,
|
||||
source: 'github' as const,
|
||||
isDownloaded: false, // Will be updated by status check
|
||||
isUpToDate: false, // Will be updated by status check
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(scriptMap.values());
|
||||
}, [scriptCardsData]);
|
||||
|
||||
// Update scripts with download status and filter to only downloaded scripts
|
||||
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
|
||||
return combinedScripts
|
||||
.map(script => {
|
||||
if (!script?.name) {
|
||||
return script; // Return as-is if invalid
|
||||
}
|
||||
|
||||
// Check if there's a corresponding local script
|
||||
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
|
||||
if (!local?.name) return false;
|
||||
const localName = local.name.replace(/\.sh$/, '');
|
||||
return localName.toLowerCase() === script.name.toLowerCase() ||
|
||||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
|
||||
}) ?? false;
|
||||
|
||||
return {
|
||||
...script,
|
||||
isDownloaded: hasLocalVersion,
|
||||
};
|
||||
})
|
||||
.filter(script => script.isDownloaded); // Only show downloaded scripts
|
||||
}, [combinedScripts, localScriptsData]);
|
||||
|
||||
// Count scripts per category (using downloaded scripts only)
|
||||
const categoryCounts = React.useMemo((): Record<string, number> => {
|
||||
if (!scriptCardsData?.success) return {};
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
// Initialize all categories with 0
|
||||
categories.forEach((categoryName: string) => {
|
||||
counts[categoryName] = 0;
|
||||
});
|
||||
|
||||
// Count each unique downloaded script only once per category
|
||||
downloadedScripts.forEach(script => {
|
||||
if (script.categoryNames && script.slug) {
|
||||
const countedCategories = new Set<string>();
|
||||
script.categoryNames.forEach((categoryName: unknown) => {
|
||||
if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) {
|
||||
countedCategories.add(categoryName);
|
||||
counts[categoryName]++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [categories, downloadedScripts, scriptCardsData?.success]);
|
||||
|
||||
// Filter scripts based on all filters and category
|
||||
const filteredScripts = React.useMemo((): ScriptCardType[] => {
|
||||
let scripts = downloadedScripts;
|
||||
|
||||
// Filter by search query
|
||||
if (filters.searchQuery?.trim()) {
|
||||
const query = filters.searchQuery.toLowerCase().trim();
|
||||
|
||||
if (query.length >= 1) {
|
||||
scripts = scripts.filter(script => {
|
||||
if (!script || typeof script !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = (script.name ?? '').toLowerCase();
|
||||
const slug = (script.slug ?? '').toLowerCase();
|
||||
|
||||
return name.includes(query) ?? slug.includes(query);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by category using real category data from downloaded scripts
|
||||
if (selectedCategory) {
|
||||
scripts = scripts.filter(script => {
|
||||
if (!script) return false;
|
||||
|
||||
// Check if the downloaded script has categoryNames that include the selected category
|
||||
return script.categoryNames?.includes(selectedCategory) ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by updateable status
|
||||
if (filters.showUpdatable !== null) {
|
||||
scripts = scripts.filter(script => {
|
||||
if (!script) return false;
|
||||
const isUpdatable = script.updateable ?? false;
|
||||
return filters.showUpdatable ? isUpdatable : !isUpdatable;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by script types
|
||||
if (filters.selectedTypes.length > 0) {
|
||||
scripts = scripts.filter(script => {
|
||||
if (!script) return false;
|
||||
const scriptType = (script.type ?? '').toLowerCase();
|
||||
return filters.selectedTypes.some(type => type.toLowerCase() === scriptType);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
scripts.sort((a, b) => {
|
||||
if (!a || !b) return 0;
|
||||
|
||||
let compareValue = 0;
|
||||
|
||||
switch (filters.sortBy) {
|
||||
case 'name':
|
||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||
break;
|
||||
case 'created':
|
||||
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
|
||||
const aCreated = a?.date_created ?? '';
|
||||
const bCreated = b?.date_created ?? '';
|
||||
|
||||
// If both have dates, compare them directly
|
||||
if (aCreated && bCreated) {
|
||||
// For dates: asc = oldest first (2020 before 2024), desc = newest first (2024 before 2020)
|
||||
compareValue = aCreated.localeCompare(bCreated);
|
||||
} else if (aCreated && !bCreated) {
|
||||
// Scripts with dates come before scripts without dates
|
||||
compareValue = -1;
|
||||
} else if (!aCreated && bCreated) {
|
||||
// Scripts without dates come after scripts with dates
|
||||
compareValue = 1;
|
||||
} else {
|
||||
// Both have no dates, fallback to name comparison
|
||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||
}
|
||||
|
||||
// Apply sort order
|
||||
return filters.sortOrder === 'asc' ? compareValue : -compareValue;
|
||||
});
|
||||
|
||||
return scripts;
|
||||
}, [downloadedScripts, filters, selectedCategory]);
|
||||
|
||||
// Calculate filter counts for FilterBar
|
||||
const filterCounts = React.useMemo(() => {
|
||||
const updatableCount = downloadedScripts.filter(script => script?.updateable).length;
|
||||
|
||||
return { installedCount: downloadedScripts.length, updatableCount };
|
||||
}, [downloadedScripts]);
|
||||
|
||||
// Handle filter changes
|
||||
const handleFiltersChange = (newFilters: FilterState) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
// Handle category selection with auto-scroll
|
||||
const handleCategorySelect = (category: string | null) => {
|
||||
setSelectedCategory(category);
|
||||
};
|
||||
|
||||
// Auto-scroll effect when category changes
|
||||
useEffect(() => {
|
||||
if (selectedCategory && gridRef.current) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
gridRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [selectedCategory]);
|
||||
|
||||
const handleCardClick = (scriptCard: { slug: string }) => {
|
||||
// All scripts are GitHub scripts, open modal
|
||||
setSelectedSlug(scriptCard.slug);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedSlug(null);
|
||||
};
|
||||
|
||||
if (githubLoading || localLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (githubError || localError) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-600 mb-4">
|
||||
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium">Failed to load downloaded scripts</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
variant="default"
|
||||
size="default"
|
||||
className="mt-4"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!downloadedScripts || downloadedScripts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-muted-foreground">
|
||||
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium">No downloaded scripts found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You haven't downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||
{/* Category Sidebar */}
|
||||
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
totalScripts={downloadedScripts.length}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategorySelect={handleCategorySelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
||||
{/* Enhanced Filter Bar */}
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
totalScripts={downloadedScripts.length}
|
||||
filteredCount={filteredScripts.length}
|
||||
updatableCount={filterCounts.updatableCount}
|
||||
saveFiltersEnabled={saveFiltersEnabled}
|
||||
isLoadingFilters={isLoadingFilters}
|
||||
/>
|
||||
|
||||
{/* View Toggle */}
|
||||
<ViewToggle
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Scripts Grid */}
|
||||
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-muted-foreground">
|
||||
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium">No matching downloaded scripts found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Try different filter settings or clear all filters.
|
||||
</p>
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
{filters.searchQuery && (
|
||||
<Button
|
||||
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
|
||||
variant="default"
|
||||
size="default"
|
||||
>
|
||||
Clear Search
|
||||
</Button>
|
||||
)}
|
||||
{selectedCategory && (
|
||||
<Button
|
||||
onClick={() => handleCategorySelect(null)}
|
||||
variant="secondary"
|
||||
size="default"
|
||||
>
|
||||
Clear Category
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredScripts.map((script, index) => {
|
||||
// Add validation to ensure script has required properties
|
||||
if (!script || typeof script !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||
|
||||
return (
|
||||
<ScriptCard
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredScripts.map((script, index) => {
|
||||
// Add validation to ensure script has required properties
|
||||
if (!script || typeof script !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||
|
||||
return (
|
||||
<ScriptCardList
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<ScriptDetailModal
|
||||
script={scriptData?.success ? scriptData.script : null}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onInstallScript={onInstallScript}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Server } from '../../types/server';
|
||||
import { Button } from './ui/button';
|
||||
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
|
||||
|
||||
interface ExecutionModeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,8 +18,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local');
|
||||
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -23,6 +27,20 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-select server when exactly one server is available
|
||||
useEffect(() => {
|
||||
if (isOpen && !loading && servers.length === 1) {
|
||||
setSelectedServer(servers[0] ?? null);
|
||||
}
|
||||
}, [isOpen, loading, servers]);
|
||||
|
||||
// Refresh servers when settings modal closes
|
||||
const handleSettingsModalClose = () => {
|
||||
setSettingsModalOpen(false);
|
||||
// Refetch servers to reflect any changes made in settings
|
||||
void fetchServers();
|
||||
};
|
||||
|
||||
const fetchServers = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -32,7 +50,11 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
throw new Error('Failed to fetch servers');
|
||||
}
|
||||
const data = await response.json();
|
||||
setServers(data as Server[]);
|
||||
// Sort servers by name alphabetically
|
||||
const sortedServers = (data as Server[]).sort((a, b) =>
|
||||
(a.name ?? '').localeCompare(b.name ?? '')
|
||||
);
|
||||
setServers(sortedServers);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
@@ -41,166 +63,175 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
if (selectedMode === 'ssh' && !selectedServer) {
|
||||
if (!selectedServer) {
|
||||
setError('Please select a server for SSH execution');
|
||||
return;
|
||||
}
|
||||
|
||||
onExecute(selectedMode, selectedServer ?? undefined);
|
||||
onExecute('ssh', selectedServer);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleModeChange = (mode: 'local' | 'ssh') => {
|
||||
setSelectedMode(mode);
|
||||
if (mode === 'local') {
|
||||
setSelectedServer(null);
|
||||
}
|
||||
|
||||
const handleServerSelect = (server: Server | null) => {
|
||||
setSelectedServer(server);
|
||||
};
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<>
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-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">Select Server</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">
|
||||
Where would you like to execute "{scriptName}"?
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Execution Mode Selection */}
|
||||
<div className="space-y-4 mb-6">
|
||||
|
||||
|
||||
{/* SSH Execution */}
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'ssh'
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => handleModeChange('ssh')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id="ssh"
|
||||
name="executionMode"
|
||||
value="ssh"
|
||||
checked={selectedMode === 'ssh'}
|
||||
onChange={() => handleModeChange('ssh')}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<label htmlFor="ssh" className="ml-3 flex-1 cursor-pointer">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-gray-900">SSH Execution</h4>
|
||||
<p className="text-sm text-gray-500">Run the script on a remote server</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Selection (only for SSH mode) */}
|
||||
{selectedMode === 'ssh' && (
|
||||
<div className="mb-6">
|
||||
<label htmlFor="server" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Server
|
||||
</label>
|
||||
{loading ? (
|
||||
<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="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">No servers configured</p>
|
||||
<p className="text-xs mt-1">Add servers in Settings to use SSH execution</p>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="server"
|
||||
value={selectedServer?.id ?? ''}
|
||||
onChange={(e) => {
|
||||
const serverId = parseInt(e.target.value);
|
||||
const server = servers.find(s => s.id === serverId);
|
||||
setSelectedServer(server ?? null);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
<p className="text-xs mt-1">Add servers in Settings to execute scripts</p>
|
||||
<Button
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
>
|
||||
<option value="">Select a server...</option>
|
||||
{servers.map((server) => (
|
||||
<option key={server.id} value={server.id}>
|
||||
{server.name} ({server.ip}) - {server.user}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
Open Server Settings
|
||||
</Button>
|
||||
</div>
|
||||
) : servers.length === 1 ? (
|
||||
/* Single Server Confirmation View */
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
Install Script Confirmation
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Do you want to install "{scriptName}" on the following server?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{selectedServer?.name ?? 'Unnamed Server'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedServer?.ip}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
<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"
|
||||
>
|
||||
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'}
|
||||
</button>
|
||||
</div>
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Multiple Servers Selection View */
|
||||
<div className="space-y-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
Select server to execute "{scriptName}"
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Server Selection */}
|
||||
<div className="mb-6">
|
||||
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
|
||||
Select Server
|
||||
</label>
|
||||
<ColorCodedDropdown
|
||||
servers={servers}
|
||||
selectedServer={selectedServer}
|
||||
onServerSelect={handleServerSelect}
|
||||
placeholder="Select a server..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
disabled={!selectedServer}
|
||||
variant="default"
|
||||
size="default"
|
||||
className={!selectedServer ? 'bg-gray-400 cursor-not-allowed' : ''}
|
||||
>
|
||||
Run on Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Settings Modal */}
|
||||
<SettingsModal
|
||||
isOpen={settingsModalOpen}
|
||||
onClose={handleSettingsModalClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
|
||||
|
||||
export interface FilterState {
|
||||
searchQuery: string;
|
||||
@@ -16,13 +18,15 @@ interface FilterBarProps {
|
||||
totalScripts: number;
|
||||
filteredCount: number;
|
||||
updatableCount?: number;
|
||||
saveFiltersEnabled?: boolean;
|
||||
isLoadingFilters?: boolean;
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -31,8 +35,11 @@ export function FilterBar({
|
||||
totalScripts,
|
||||
filteredCount,
|
||||
updatableCount = 0,
|
||||
saveFiltersEnabled = false,
|
||||
isLoadingFilters = false,
|
||||
}: FilterBarProps) {
|
||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||
|
||||
const updateFilters = (updates: Partial<FilterState>) => {
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
@@ -74,13 +81,35 @@ 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-4 sm:p-6 shadow-sm">
|
||||
{/* Loading State */}
|
||||
{isLoadingFilters && (
|
||||
<div className="mb-4 flex items-center justify-center py-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<span>Loading saved filters...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Persistence Status */}
|
||||
{!isLoadingFilters && saveFiltersEnabled && (
|
||||
<div className="mb-4 flex items-center justify-center py-1">
|
||||
<div className="flex items-center space-x-2 text-xs text-green-600">
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Filters are being saved automatically</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative max-w-md">
|
||||
<div className="relative max-w-md w-full">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<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 +127,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,15 +149,15 @@ export function FilterBar({
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
||||
{/* Updateable Filter */}
|
||||
<button
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next =
|
||||
filters.showUpdatable === null
|
||||
@@ -136,27 +167,33 @@ export function FilterBar({
|
||||
: null;
|
||||
updateFilters({ showUpdatable: next });
|
||||
}}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||
filters.showUpdatable === null
|
||||
? "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: filters.showUpdatable === true
|
||||
? "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"
|
||||
? "border border-green-500/20 bg-green-500/10 text-green-400"
|
||||
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{getUpdatableButtonText()}
|
||||
</button>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>{getUpdatableButtonText()}</span>
|
||||
</Button>
|
||||
|
||||
{/* Type Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<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={`w-full flex items-center justify-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 hover:text-accent-foreground"
|
||||
: "border border-primary/20 bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{getTypeButtonText()}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
||||
@@ -171,15 +208,17 @@ 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) => (
|
||||
{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-gray-50 dark:hover:bg-gray-700"
|
||||
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -200,52 +239,104 @@ export function FilterBar({
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
|
||||
className="rounded border-input text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-lg">{type.icon}</span>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort By Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{filters.sortBy === "name" ? (
|
||||
<FileText className="h-4 w-4" />
|
||||
) : (
|
||||
<Calendar className="h-4 w-4" />
|
||||
)}
|
||||
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{isSortDropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "name" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">By Name</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "created" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">By Created Date</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Options */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Sort By Dropdown */}
|
||||
<select
|
||||
value={filters.sortBy}
|
||||
onChange={(e) =>
|
||||
updateFilters({ sortBy: e.target.value as "name" | "created" })
|
||||
}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:focus:ring-blue-400"
|
||||
>
|
||||
<option value="name">📝 By Name</option>
|
||||
<option value="created">📅 By Created Date</option>
|
||||
</select>
|
||||
|
||||
{/* Sort Order Button */}
|
||||
<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="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{filters.sortOrder === "asc" ? (
|
||||
<>
|
||||
@@ -286,20 +377,19 @@ export function FilterBar({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</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="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{filteredCount === totalScripts ? (
|
||||
<span>Showing all {totalScripts} scripts</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredCount} of {totalScripts} scripts{" "}
|
||||
{hasActiveFilters && (
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">
|
||||
<span className="font-medium text-blue-600">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
@@ -308,9 +398,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 w-full sm:w-auto justify-center sm:justify-start"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -326,15 +418,18 @@ export function FilterBar({
|
||||
/>
|
||||
</svg>
|
||||
<span>Clear all filters</span>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Click outside to close dropdown */}
|
||||
{isTypeDropdownOpen && (
|
||||
{/* Click outside to close dropdowns */}
|
||||
{(isTypeDropdownOpen || isSortDropdownOpen) && (
|
||||
<div
|
||||
className="fixed inset-0 z-0"
|
||||
onClick={() => setIsTypeDropdownOpen(false)}
|
||||
onClick={() => {
|
||||
setIsTypeDropdownOpen(false);
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
593
src/app/_components/GeneralSettingsModal.tsx
Normal file
593
src/app/_components/GeneralSettingsModal.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Toggle } from './ui/toggle';
|
||||
|
||||
interface GeneralSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [saveFilter, setSaveFilter] = useState(false);
|
||||
const [savedFilters, setSavedFilters] = useState<any>(null);
|
||||
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
// Auth state
|
||||
const [authUsername, setAuthUsername] = useState('');
|
||||
const [authPassword, setAuthPassword] = useState('');
|
||||
const [authConfirmPassword, setAuthConfirmPassword] = useState('');
|
||||
const [authEnabled, setAuthEnabled] = useState(false);
|
||||
const [authHasCredentials, setAuthHasCredentials] = useState(false);
|
||||
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
|
||||
// Load existing settings when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadGithubToken();
|
||||
void loadSaveFilter();
|
||||
void loadSavedFilters();
|
||||
void loadAuthCredentials();
|
||||
void loadColorCodingSetting();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadGithubToken = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/settings/github-token');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGithubToken((data.token as string) ?? '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading GitHub token:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSaveFilter = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/save-filter');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSaveFilter((data.enabled as boolean) ?? false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading save filter setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSaveFilter = async (enabled: boolean) => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/save-filter', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSaveFilter(enabled);
|
||||
setMessage({ type: 'success', text: 'Save filter setting updated!' });
|
||||
|
||||
// If disabling save filters, clear saved filters
|
||||
if (!enabled) {
|
||||
await clearSavedFilters();
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save setting' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save setting' });
|
||||
}
|
||||
};
|
||||
|
||||
const loadSavedFilters = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/filters');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSavedFilters(data.filters);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading saved filters:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSavedFilters = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/filters', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSavedFilters(null);
|
||||
setMessage({ type: 'success', text: 'Saved filters cleared!' });
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({ type: 'error', text: errorData.error ?? 'Failed to clear filters' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to clear filters' });
|
||||
}
|
||||
};
|
||||
|
||||
const saveGithubToken = async () => {
|
||||
setIsSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/github-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: githubToken }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: 'GitHub token saved successfully!' });
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save token' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save token' });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColorCodingSetting = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/color-coding');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setColorCodingEnabled(Boolean(data.enabled));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading color coding setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveColorCodingSetting = async (enabled: boolean) => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/color-coding', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setColorCodingEnabled(enabled);
|
||||
setMessage({ type: 'success', text: 'Color coding setting saved successfully' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving color coding setting:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAuthCredentials = async () => {
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/settings/auth-credentials');
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean };
|
||||
setAuthUsername(data.username ?? '');
|
||||
setAuthEnabled(data.enabled ?? false);
|
||||
setAuthHasCredentials(data.hasCredentials ?? false);
|
||||
setAuthSetupCompleted(data.setupCompleted ?? false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading auth credentials:', error);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAuthCredentials = async () => {
|
||||
if (authPassword !== authConfirmPassword) {
|
||||
setMessage({ type: 'error', text: 'Passwords do not match' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/auth-credentials', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: authUsername,
|
||||
password: authPassword,
|
||||
enabled: authEnabled
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: 'Authentication credentials updated successfully!' });
|
||||
setAuthPassword('');
|
||||
setAuthConfirmPassword('');
|
||||
void loadAuthCredentials();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save credentials' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save credentials' });
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAuthEnabled = async (enabled: boolean) => {
|
||||
setAuthLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/auth-credentials', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setAuthEnabled(enabled);
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully!`
|
||||
});
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({ type: 'error', text: errorData.error ?? 'Failed to update auth status' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to update auth status' });
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-8 px-4 sm:px-6">
|
||||
<Button
|
||||
onClick={() => setActiveTab('general')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'general'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
General
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('github')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'github'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('auth')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'auth'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
Authentication
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||
Configure general application preferences and behavior.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Save Filters</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">Save your configured script filters.</p>
|
||||
<Toggle
|
||||
checked={saveFilter}
|
||||
onCheckedChange={saveSaveFilter}
|
||||
label="Enable filter saving"
|
||||
/>
|
||||
|
||||
{saveFilter && (
|
||||
<div className="mt-4 p-3 bg-muted rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Saved Filters</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{savedFilters ? 'Filters are currently saved' : 'No filters saved yet'}
|
||||
</p>
|
||||
{savedFilters && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<div>Search: {savedFilters.searchQuery ?? 'None'}</div>
|
||||
<div>Types: {savedFilters.selectedTypes?.length ?? 0} selected</div>
|
||||
<div>Sort: {savedFilters.sortBy} ({savedFilters.sortOrder})</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{savedFilters && (
|
||||
<Button
|
||||
onClick={clearSavedFilters}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">Enable color coding for servers to visually distinguish them throughout the application.</p>
|
||||
<Toggle
|
||||
checked={colorCodingEnabled}
|
||||
onCheckedChange={saveColorCodingSetting}
|
||||
label="Enable server color coding"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'github' && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">GitHub Integration</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||
Configure GitHub integration for script management and updates.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">GitHub Personal Access Token</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">Save a GitHub Personal Access Token to circumvent GitHub API rate limits.</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="github-token" className="block text-sm font-medium text-foreground mb-1">
|
||||
Token
|
||||
</label>
|
||||
<Input
|
||||
id="github-token"
|
||||
type="password"
|
||||
placeholder="Enter your GitHub Personal Access Token"
|
||||
value={githubToken}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGithubToken(e.target.value)}
|
||||
disabled={isLoading || isSaving}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`p-3 rounded-md text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 text-green-800 border border-green-200'
|
||||
: 'bg-red-50 text-red-800 border border-red-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={saveGithubToken}
|
||||
disabled={isSaving || isLoading || !githubToken.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Token'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={loadGithubToken}
|
||||
disabled={isLoading || isSaving}
|
||||
variant="outline"
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'auth' && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||
Configure authentication to secure access to your application.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Authentication Status</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{authSetupCompleted
|
||||
? (authHasCredentials
|
||||
? `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. Current username: ${authUsername}`
|
||||
: `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. No credentials configured.`)
|
||||
: 'Authentication setup has not been completed yet.'
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Enable Authentication</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{authEnabled
|
||||
? 'Authentication is required on every page load'
|
||||
: 'Authentication is optional'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={authEnabled}
|
||||
onCheckedChange={toggleAuthEnabled}
|
||||
disabled={authLoading || !authSetupCompleted}
|
||||
label="Enable authentication"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Change your username and password for authentication.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="auth-username" className="block text-sm font-medium text-foreground mb-1">
|
||||
Username
|
||||
</label>
|
||||
<Input
|
||||
id="auth-username"
|
||||
type="text"
|
||||
placeholder="Enter username"
|
||||
value={authUsername}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthUsername(e.target.value)}
|
||||
disabled={authLoading}
|
||||
className="w-full"
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="auth-password" className="block text-sm font-medium text-foreground mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
id="auth-password"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
value={authPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthPassword(e.target.value)}
|
||||
disabled={authLoading}
|
||||
className="w-full"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="auth-confirm-password" className="block text-sm font-medium text-foreground mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
id="auth-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
value={authConfirmPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthConfirmPassword(e.target.value)}
|
||||
disabled={authLoading}
|
||||
className="w-full"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`p-3 rounded-md text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 text-green-800 border border-green-200'
|
||||
: 'bg-red-50 text-red-800 border border-red-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={saveAuthCredentials}
|
||||
disabled={authLoading || !authUsername.trim() || !authPassword.trim() || !authConfirmPassword.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
{authLoading ? 'Saving...' : 'Update Credentials'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={loadAuthCredentials}
|
||||
disabled={authLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{authLoading ? 'Loading...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } 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';
|
||||
import { ScriptInstallationCard } from './ScriptInstallationCard';
|
||||
import { getContrastColor } from '../../lib/colorUtils';
|
||||
|
||||
interface InstalledScript {
|
||||
id: number;
|
||||
@@ -15,7 +18,7 @@ interface InstalledScript {
|
||||
server_ip: string | null;
|
||||
server_user: string | null;
|
||||
server_password: string | null;
|
||||
execution_mode: 'local' | 'ssh';
|
||||
server_color: string | null;
|
||||
installation_date: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log: string | null;
|
||||
@@ -25,11 +28,18 @@ 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 [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('script_name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
||||
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
||||
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' });
|
||||
const [showAutoDetectForm, setShowAutoDetectForm] = useState(false);
|
||||
const [autoDetectServerId, setAutoDetectServerId] = useState<string>('');
|
||||
const [autoDetectStatus, setAutoDetectStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
|
||||
const [cleanupStatus, setCleanupStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
|
||||
const cleanupRunRef = useRef(false);
|
||||
|
||||
// Fetch installed scripts
|
||||
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
@@ -67,12 +77,90 @@ export function InstalledScriptsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-detect LXC containers mutation
|
||||
const autoDetectMutation = api.installedScripts.autoDetectLXCContainers.useMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('Auto-detect success:', data);
|
||||
void refetchScripts();
|
||||
setShowAutoDetectForm(false);
|
||||
setAutoDetectServerId('');
|
||||
|
||||
// Show detailed message about what was added/skipped
|
||||
let statusMessage = data.message ?? 'Auto-detection completed successfully!';
|
||||
if (data.skippedContainers && data.skippedContainers.length > 0) {
|
||||
const skippedNames = data.skippedContainers.map((c: any) => String(c.hostname)).join(', ');
|
||||
statusMessage += ` Skipped duplicates: ${skippedNames}`;
|
||||
}
|
||||
|
||||
setAutoDetectStatus({
|
||||
type: 'success',
|
||||
message: statusMessage
|
||||
});
|
||||
// Clear status after 8 seconds (longer for detailed info)
|
||||
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Auto-detect mutation error:', error);
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
data: error.data
|
||||
});
|
||||
setAutoDetectStatus({
|
||||
type: 'error',
|
||||
message: error.message ?? 'Auto-detection failed. Please try again.'
|
||||
});
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup orphaned scripts mutation
|
||||
const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('Cleanup success:', data);
|
||||
void refetchScripts();
|
||||
|
||||
if (data.deletedCount > 0) {
|
||||
setCleanupStatus({
|
||||
type: 'success',
|
||||
message: `Cleanup completed! Removed ${data.deletedCount} orphaned script(s): ${data.deletedScripts.join(', ')}`
|
||||
});
|
||||
} else {
|
||||
setCleanupStatus({
|
||||
type: 'success',
|
||||
message: 'Cleanup completed! No orphaned scripts found.'
|
||||
});
|
||||
}
|
||||
// Clear status after 8 seconds (longer for cleanup info)
|
||||
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 8000);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Cleanup mutation error:', error);
|
||||
setCleanupStatus({
|
||||
type: 'error',
|
||||
message: error.message ?? 'Cleanup failed. Please try again.'
|
||||
});
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
|
||||
const stats = statsData?.stats;
|
||||
|
||||
// Filter scripts based on search and filters
|
||||
const filteredScripts = scripts.filter((script: InstalledScript) => {
|
||||
// Run cleanup when component mounts and scripts are loaded (only once)
|
||||
useEffect(() => {
|
||||
if (scripts.length > 0 && serversData?.servers && !cleanupMutation.isPending && !cleanupRunRef.current) {
|
||||
console.log('Running automatic cleanup check...');
|
||||
cleanupRunRef.current = true;
|
||||
void cleanupMutation.mutate();
|
||||
}
|
||||
}, [scripts.length, serversData?.servers, cleanupMutation]);
|
||||
|
||||
// Filter and sort scripts
|
||||
const filteredScripts = scripts
|
||||
.filter((script: InstalledScript) => {
|
||||
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(script.container_id?.includes(searchTerm) ?? false) ||
|
||||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
|
||||
@@ -80,10 +168,47 @@ 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;
|
||||
})
|
||||
.sort((a: InstalledScript, b: InstalledScript) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sortField) {
|
||||
case 'script_name':
|
||||
aValue = a.script_name.toLowerCase();
|
||||
bValue = b.script_name.toLowerCase();
|
||||
break;
|
||||
case 'container_id':
|
||||
aValue = a.container_id ?? '';
|
||||
bValue = b.container_id ?? '';
|
||||
break;
|
||||
case 'server_name':
|
||||
aValue = a.server_name ?? 'Local';
|
||||
bValue = b.server_name ?? 'Local';
|
||||
break;
|
||||
case 'status':
|
||||
aValue = a.status;
|
||||
bValue = b.status;
|
||||
break;
|
||||
case 'installation_date':
|
||||
aValue = new Date(a.installation_date).getTime();
|
||||
bValue = new Date(b.installation_date).getTime();
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aValue < bValue) {
|
||||
return sortDirection === 'asc' ? -1 : 1;
|
||||
}
|
||||
if (aValue > bValue) {
|
||||
return sortDirection === 'asc' ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Get unique servers for filter
|
||||
@@ -111,7 +236,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 +249,7 @@ export function InstalledScriptsTab() {
|
||||
setUpdatingScript({
|
||||
id: script.id,
|
||||
containerId: script.container_id,
|
||||
server: server,
|
||||
mode: script.execution_mode
|
||||
server: server
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -197,6 +321,34 @@ export function InstalledScriptsTab() {
|
||||
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
|
||||
};
|
||||
|
||||
const handleAutoDetect = () => {
|
||||
if (!autoDetectServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoDetectMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoDetectStatus({ type: null, message: '' });
|
||||
console.log('Starting auto-detect for server ID:', autoDetectServerId);
|
||||
autoDetectMutation.mutate({ serverId: Number(autoDetectServerId) });
|
||||
};
|
||||
|
||||
const handleCancelAutoDetect = () => {
|
||||
setShowAutoDetectForm(false);
|
||||
setAutoDetectServerId('');
|
||||
};
|
||||
|
||||
const handleSort = (field: 'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date') => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
@@ -205,7 +357,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 +370,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 +379,87 @@ 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
|
||||
{/* Add Script and Auto-Detect Buttons */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row gap-3">
|
||||
<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>
|
||||
<Button
|
||||
onClick={() => setShowAutoDetectForm(!showAutoDetectForm)}
|
||||
variant={showAutoDetectForm ? "outline" : "secondary"}
|
||||
size="default"
|
||||
>
|
||||
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
|
||||
</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-4 sm:p-6 bg-card rounded-lg border border-border shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Add Manual Script Entry</h3>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Script Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addFormData.script_name}
|
||||
onChange={(e) => handleAddFormChange('script_name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-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,40 +468,186 @@ export function InstalledScriptsTab() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3 mt-4">
|
||||
<button
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm: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"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
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"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{createScriptMutation.isPending ? 'Adding...' : 'Add Script'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Messages */}
|
||||
{(autoDetectStatus.type ?? cleanupStatus.type) && (
|
||||
<div className="mb-4 space-y-2">
|
||||
{/* Auto-Detect Status Message */}
|
||||
{autoDetectStatus.type && (
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
autoDetectStatus.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{autoDetectStatus.type === 'success' ? (
|
||||
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm font-medium ${
|
||||
autoDetectStatus.type === 'success'
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: 'text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{autoDetectStatus.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cleanup Status Message */}
|
||||
{cleanupStatus.type && (
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
cleanupStatus.type === 'success'
|
||||
? 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700'
|
||||
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{cleanupStatus.type === 'success' ? (
|
||||
<svg className="h-5 w-5 text-slate-500 dark:text-slate-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm font-medium ${
|
||||
cleanupStatus.type === 'success'
|
||||
? 'text-slate-700 dark:text-slate-300'
|
||||
: 'text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{cleanupStatus.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-Detect LXC Containers Form */}
|
||||
{showAutoDetectForm && (
|
||||
<div className="mb-6 p-4 sm:p-6 bg-card rounded-lg border border-border shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Auto-Detect LXC Containers (Must contain a tag with "community-script")</h3>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-slate-500 dark:text-slate-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
How it works
|
||||
</h4>
|
||||
<div className="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<p>This feature will:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Connect to the selected server via SSH</li>
|
||||
<li>Scan all LXC config files in /etc/pve/lxc/</li>
|
||||
<li>Find containers with "community-script" in their tags</li>
|
||||
<li>Extract the container ID and hostname</li>
|
||||
<li>Add them as installed script entries</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Select Server *
|
||||
</label>
|
||||
<select
|
||||
value={autoDetectServerId}
|
||||
onChange={(e) => setAutoDetectServerId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
>
|
||||
<option value="">Choose a server...</option>
|
||||
{serversData?.servers?.map((server: any) => (
|
||||
<option key={server.id} value={server.id}>
|
||||
{server.name} ({server.ip})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm:mt-6">
|
||||
<Button
|
||||
onClick={handleCancelAutoDetect}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAutoDetect}
|
||||
disabled={autoDetectMutation.isPending || !autoDetectServerId}
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{autoDetectMutation.isPending ? '🔍 Scanning...' : '🔍 Start Auto-Detection'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-64">
|
||||
<div className="space-y-4">
|
||||
{/* Search Input - Full Width on Mobile */}
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="text"
|
||||
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>
|
||||
|
||||
{/* Filter Dropdowns - Responsive Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="success">Success</option>
|
||||
@@ -352,7 +658,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="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="all">All Servers</option>
|
||||
<option value="local">Local</option>
|
||||
@@ -362,44 +668,118 @@ export function InstalledScriptsTab() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scripts Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{/* Scripts Display - Mobile Cards / Desktop Table */}
|
||||
<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">
|
||||
<>
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="block md:hidden p-4 space-y-4">
|
||||
{filteredScripts.map((script) => (
|
||||
<ScriptInstallationCard
|
||||
key={script.id}
|
||||
script={script}
|
||||
isEditing={editingScriptId === script.id}
|
||||
editFormData={editFormData}
|
||||
onInputChange={handleInputChange}
|
||||
onEdit={() => handleEditScript(script)}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
onUpdate={() => handleUpdateScript(script)}
|
||||
onDelete={() => handleDeleteScript(Number(script.id))}
|
||||
isUpdating={updateScriptMutation.isPending}
|
||||
isDeleting={deleteScriptMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop Table Layout */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<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">
|
||||
Script Name
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('script_name')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Script Name</span>
|
||||
{sortField === 'script_name' && (
|
||||
<span className="text-primary">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Container ID
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('container_id')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Container ID</span>
|
||||
{sortField === 'container_id' && (
|
||||
<span className="text-primary">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Server
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('server_name')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Server</span>
|
||||
{sortField === 'server_name' && (
|
||||
<span className="text-primary">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Status</span>
|
||||
{sortField === 'status' && (
|
||||
<span className="text-primary">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Status
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('installation_date')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Installation Date</span>
|
||||
{sortField === 'installation_date' && (
|
||||
<span className="text-primary">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 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"
|
||||
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingScriptId === script.id ? (
|
||||
<div className="space-y-2">
|
||||
@@ -407,15 +787,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 +805,82 @@ 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 px-3 py-1 rounded"
|
||||
style={{
|
||||
backgroundColor: script.server_color ?? 'transparent',
|
||||
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
|
||||
}}
|
||||
>
|
||||
{script.server_name ?? '-'}
|
||||
</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>
|
||||
@@ -509,6 +890,7 @@ export function InstalledScriptsTab() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
|
||||
interface ProxmoxCheckProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProxmoxCheck({ children }: ProxmoxCheckProps) {
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isProxmoxVE, setIsProxmoxVE] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: proxmoxData, isLoading } = api.scripts.checkProxmoxVE.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (proxmoxData && typeof proxmoxData === 'object' && 'success' in proxmoxData) {
|
||||
setIsChecking(false);
|
||||
if (proxmoxData.success) {
|
||||
const isProxmox = 'isProxmoxVE' in proxmoxData ? proxmoxData.isProxmoxVE as boolean : false;
|
||||
setIsProxmoxVE(isProxmox);
|
||||
if (!isProxmox) {
|
||||
setError('This application can only run on a Proxmox VE Host');
|
||||
}
|
||||
} else {
|
||||
const errorMsg = 'error' in proxmoxData ? proxmoxData.error as string : 'Failed to check Proxmox VE status';
|
||||
setError(errorMsg);
|
||||
setIsProxmoxVE(false);
|
||||
}
|
||||
}
|
||||
}, [proxmoxData]);
|
||||
|
||||
// Show loading state
|
||||
if (isChecking || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Checking system requirements...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error if not running on Proxmox VE
|
||||
if (!isProxmoxVE || error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="max-w-md mx-auto text-center">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-8">
|
||||
<div className="flex items-center justify-center w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full">
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 19.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-red-800 mb-2">
|
||||
System Requirements Not Met
|
||||
</h1>
|
||||
<p className="text-red-700 mb-4">
|
||||
{error ?? 'This application can only run on a Proxmox VE Host'}
|
||||
</p>
|
||||
<div className="text-sm text-red-600 bg-red-100 rounded-lg p-4">
|
||||
<p className="font-medium mb-2">To use this application, you need:</p>
|
||||
<ul className="text-left space-y-1">
|
||||
<li>• A Proxmox VE host system</li>
|
||||
<li>• The <code className="bg-red-200 px-1 rounded">pveversion</code> command must be available</li>
|
||||
<li>• Proper permissions to execute system commands</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-6 px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Retry Check
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If running on Proxmox VE, render the children
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
191
src/app/_components/SSHKeyInput.tsx
Normal file
191
src/app/_components/SSHKeyInput.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface SSHKeyInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHKeyInputProps) {
|
||||
const [inputMode, setInputMode] = useState<'upload' | 'paste'>('upload');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateSSHKey = (keyContent: string): boolean => {
|
||||
const trimmed = keyContent.trim();
|
||||
return (
|
||||
trimmed.includes('BEGIN') &&
|
||||
trimmed.includes('PRIVATE KEY') &&
|
||||
trimmed.includes('END') &&
|
||||
trimmed.includes('PRIVATE KEY')
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileUpload = (file: File) => {
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
if (validateSSHKey(content)) {
|
||||
onChange(content);
|
||||
onError?.('');
|
||||
} else {
|
||||
onError?.('Invalid SSH key format. Please ensure the file contains a valid private key.');
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
onError?.('Failed to read the file. Please try again.');
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasteChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const content = event.target.value;
|
||||
onChange(content);
|
||||
|
||||
if (content.trim() && !validateSSHKey(content)) {
|
||||
onError?.('Invalid SSH key format. Please ensure the content is a valid private key.');
|
||||
} else {
|
||||
onError?.('');
|
||||
}
|
||||
};
|
||||
|
||||
const getKeyFingerprint = (keyContent: string): string => {
|
||||
// This is a simplified fingerprint - in a real implementation,
|
||||
// you might want to use a library to generate proper SSH key fingerprints
|
||||
if (!keyContent.trim()) return '';
|
||||
|
||||
const lines = keyContent.trim().split('\n');
|
||||
const keyLine = lines.find(line =>
|
||||
line.includes('BEGIN') && line.includes('PRIVATE KEY')
|
||||
);
|
||||
|
||||
if (keyLine) {
|
||||
const keyType = keyLine.includes('RSA') ? 'RSA' :
|
||||
keyLine.includes('ED25519') ? 'ED25519' :
|
||||
keyLine.includes('ECDSA') ? 'ECDSA' : 'Unknown';
|
||||
return `${keyType} key (${keyContent.length} characters)`;
|
||||
}
|
||||
|
||||
return 'Unknown key type';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={inputMode === 'upload' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setInputMode('upload')}
|
||||
disabled={disabled}
|
||||
>
|
||||
Upload File
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={inputMode === 'paste' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setInputMode('paste')}
|
||||
disabled={disabled}
|
||||
>
|
||||
Paste Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File Upload Mode */}
|
||||
{inputMode === 'upload' && (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="text-lg">📁</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Drag and drop your SSH private key here, or click to browse
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, etc.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paste Mode */}
|
||||
{inputMode === 'paste' && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
Paste your SSH private key:
|
||||
</label>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handlePasteChange}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABFwAAAAdzc2gtcn... -----END OPENSSH PRIVATE KEY-----"
|
||||
className="w-full h-32 px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring font-mono text-xs"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key Information */}
|
||||
{value && (
|
||||
<div className="p-3 bg-muted rounded-md">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">Key detected:</span> {getKeyFingerprint(value)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
⚠️ Keep your private keys secure. This key will be stored in the database.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
164
src/app/_components/ScriptCardList.tsx
Normal file
164
src/app/_components/ScriptCardList.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import type { ScriptCard } from '~/types/script';
|
||||
import { TypeBadge, UpdateableBadge } from './Badge';
|
||||
|
||||
interface ScriptCardListProps {
|
||||
script: ScriptCard;
|
||||
onClick: (script: ScriptCard) => void;
|
||||
}
|
||||
|
||||
export function ScriptCardList({ script, onClick }: ScriptCardListProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const handleImageError = () => {
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Unknown';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryNames = () => {
|
||||
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
|
||||
return script.categoryNames.join(', ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary"
|
||||
onClick={() => onClick(script)}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
{script.logo && !imageError ? (
|
||||
<Image
|
||||
src={script.logo}
|
||||
alt={`${script.name} logo`}
|
||||
width={56}
|
||||
height={56}
|
||||
className="w-14 h-14 rounded-lg object-contain"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
|
||||
<span className="text-muted-foreground text-lg font-semibold">
|
||||
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header Row */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
|
||||
{script.name || 'Unnamed Script'}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-3">
|
||||
<TypeBadge type={script.type ?? 'unknown'} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}></div>
|
||||
<span className={`text-sm font-medium ${
|
||||
script.isDownloaded ? 'text-green-700' : 'text-destructive'
|
||||
}`}>
|
||||
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Website link */}
|
||||
{script.website && (
|
||||
<a
|
||||
href={script.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1 ml-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Website</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
|
||||
{script.description || 'No description available'}
|
||||
</p>
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<span>Categories: {getCategoryNames()}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>Created: {formatDate(script.date_created)}</span>
|
||||
</div>
|
||||
{(script.os ?? script.version) && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<span>
|
||||
{script.os && script.version
|
||||
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
|
||||
: script.os
|
||||
? script.os.charAt(0).toUpperCase() + script.os.slice(1)
|
||||
: script.version
|
||||
? `Version ${script.version}`
|
||||
: ''
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{script.interface_port && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>Port: {script.interface_port}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>ID: {script.slug || 'unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { DiffViewer } from "./DiffViewer";
|
||||
import { TextViewer } from "./TextViewer";
|
||||
import { 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);
|
||||
},
|
||||
});
|
||||
@@ -119,9 +120,6 @@ export function ScriptDetailModal({
|
||||
// Pass execution mode and server info to the parent
|
||||
onInstallScript(scriptPath, scriptName, mode, server);
|
||||
|
||||
// Scroll to top of the page to see the terminal
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
|
||||
onClose(); // Close the modal when starting installation
|
||||
}
|
||||
};
|
||||
@@ -132,48 +130,75 @@ 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 mx-2 sm:mx-4 lg:mx-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
|
||||
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
{script.logo && !imageError ? (
|
||||
<Image
|
||||
src={script.logo}
|
||||
alt={`${script.name} logo`}
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-16 w-16 rounded-lg object-contain"
|
||||
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<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-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0">
|
||||
<span className="text-lg sm: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">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
|
||||
{script.name}
|
||||
</h2>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
<TypeBadge type={script.type} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
{script.privileged && <PrivilegedBadge />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 sm:h-6 sm:w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
|
||||
{/* Install Button - only show if script files exist */}
|
||||
{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="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -189,15 +214,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="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -219,7 +246,7 @@ export function ScriptDetailModal({
|
||||
/>
|
||||
</svg>
|
||||
<span>View</span>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Load/Update Script Button */}
|
||||
@@ -239,7 +266,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 +300,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 +326,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,37 +357,18 @@ export function ScriptDetailModal({
|
||||
);
|
||||
}
|
||||
})()}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load Message */}
|
||||
{loadMessage && (
|
||||
<div className="mx-6 mb-4 rounded-lg bg-blue-50 p-3 text-sm text-blue-800">
|
||||
<div className="mx-4 sm: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-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
<span>Loading script status...</span>
|
||||
@@ -385,11 +393,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="flex items-center space-x-4">
|
||||
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`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 +406,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 +434,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 break-words">
|
||||
Files: {scriptFilesData.files.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
@@ -435,64 +443,64 @@ export function ScriptDetailModal({
|
||||
})()}
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
|
||||
Description
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
<p className="text-sm sm:text-base text-muted-foreground">
|
||||
{script.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||
Basic Information
|
||||
</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 +509,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-base sm: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 +523,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 +532,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 +540,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 +556,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-base sm: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-3 sm:p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="font-medium text-gray-900 capitalize dark:text-gray-100">
|
||||
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
|
||||
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
|
||||
{method.type}
|
||||
</h4>
|
||||
<span className="font-mono text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
|
||||
{method.script}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
|
||||
<div>
|
||||
<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 +617,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-base sm: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 +648,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 +663,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">
|
||||
|
||||
186
src/app/_components/ScriptInstallationCard.tsx
Normal file
186
src/app/_components/ScriptInstallationCard.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { StatusBadge } from './Badge';
|
||||
import { getContrastColor } from '../../lib/colorUtils';
|
||||
|
||||
interface InstalledScript {
|
||||
id: number;
|
||||
script_name: string;
|
||||
script_path: string;
|
||||
container_id: string | null;
|
||||
server_id: number | null;
|
||||
server_name: string | null;
|
||||
server_ip: string | null;
|
||||
server_user: string | null;
|
||||
server_password: string | null;
|
||||
server_color: string | null;
|
||||
installation_date: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log: string | null;
|
||||
}
|
||||
|
||||
interface ScriptInstallationCardProps {
|
||||
script: InstalledScript;
|
||||
isEditing: boolean;
|
||||
editFormData: { script_name: string; container_id: string };
|
||||
onInputChange: (field: 'script_name' | 'container_id', value: string) => void;
|
||||
onEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onUpdate: () => void;
|
||||
onDelete: () => void;
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export function ScriptInstallationCard({
|
||||
script,
|
||||
isEditing,
|
||||
editFormData,
|
||||
onInputChange,
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
isUpdating,
|
||||
isDeleting
|
||||
}: ScriptInstallationCardProps) {
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
|
||||
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
|
||||
>
|
||||
{/* Header with Script Name and Status */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.script_name}
|
||||
onChange={(e) => onInputChange('script_name', e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Script name"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">{script.script_path}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground truncate">{script.script_name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{script.script_path}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
<StatusBadge status={script.status}>
|
||||
{script.status.replace('_', ' ').toUpperCase()}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-1 gap-3 mb-4">
|
||||
{/* Container ID */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Container ID</div>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.container_id}
|
||||
onChange={(e) => onInputChange('container_id', e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Container ID"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm font-mono text-foreground break-all">
|
||||
{script.container_id ?? '-'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
|
||||
<span
|
||||
className="text-sm px-3 py-1 rounded inline-block"
|
||||
style={{
|
||||
backgroundColor: script.server_color ?? 'transparent',
|
||||
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
|
||||
}}
|
||||
>
|
||||
{script.server_name ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Installation Date */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Installation Date</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(String(script.installation_date))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={isUpdating}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isUpdating ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={onEdit}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{script.container_id && (
|
||||
<Button
|
||||
onClick={onUpdate}
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { ScriptCard } from './ScriptCard';
|
||||
import { ScriptCardList } from './ScriptCardList';
|
||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||
import { CategorySidebar } from './CategorySidebar';
|
||||
import { FilterBar, type FilterState } from './FilterBar';
|
||||
import { ViewToggle } from './ViewToggle';
|
||||
import { Button } from './ui/button';
|
||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||
|
||||
|
||||
@@ -18,6 +21,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
searchQuery: '',
|
||||
showUpdatable: null,
|
||||
@@ -25,6 +29,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
@@ -34,6 +40,95 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
{ enabled: !!selectedSlug }
|
||||
);
|
||||
|
||||
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Load SAVE_FILTER setting
|
||||
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||
let saveFilterEnabled = false;
|
||||
if (saveFilterResponse.ok) {
|
||||
const saveFilterData = await saveFilterResponse.json();
|
||||
saveFilterEnabled = saveFilterData.enabled ?? false;
|
||||
setSaveFiltersEnabled(saveFilterEnabled);
|
||||
}
|
||||
|
||||
// Load saved filters if SAVE_FILTER is enabled
|
||||
if (saveFilterEnabled) {
|
||||
const filtersResponse = await fetch('/api/settings/filters');
|
||||
if (filtersResponse.ok) {
|
||||
const filtersData = await filtersResponse.json();
|
||||
if (filtersData.filters) {
|
||||
setFilters(filtersData.filters as FilterState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load view mode
|
||||
const viewModeResponse = await fetch('/api/settings/view-mode');
|
||||
if (viewModeResponse.ok) {
|
||||
const viewModeData = await viewModeResponse.json();
|
||||
const viewMode = viewModeData.viewMode;
|
||||
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
|
||||
setViewMode(viewMode);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
} finally {
|
||||
setIsLoadingFilters(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadSettings();
|
||||
}, []);
|
||||
|
||||
// Save filters when they change (if SAVE_FILTER is enabled)
|
||||
useEffect(() => {
|
||||
if (!saveFiltersEnabled || isLoadingFilters) return;
|
||||
|
||||
const saveFilters = async () => {
|
||||
try {
|
||||
await fetch('/api/settings/filters', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filters }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving filters:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce the save operation
|
||||
const timeoutId = setTimeout(() => void saveFilters(), 500);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||
|
||||
// Save view mode when it changes
|
||||
useEffect(() => {
|
||||
if (isLoadingFilters) return;
|
||||
|
||||
const saveViewMode = async () => {
|
||||
try {
|
||||
await fetch('/api/settings/view-mode', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ viewMode }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving view mode:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce the save operation
|
||||
const timeoutId = setTimeout(() => void saveViewMode(), 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [viewMode, isLoadingFilters]);
|
||||
|
||||
// Extract categories from metadata
|
||||
const categories = React.useMemo((): string[] => {
|
||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||
@@ -269,7 +364,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 +377,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 +396,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>
|
||||
@@ -313,9 +410,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||
{/* Category Sidebar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
@@ -326,7 +423,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0" ref={gridRef}>
|
||||
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
||||
{/* Enhanced Filter Bar */}
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
@@ -334,13 +431,21 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
totalScripts={scriptsWithStatus.length}
|
||||
filteredCount={filteredScripts.length}
|
||||
updatableCount={filterCounts.updatableCount}
|
||||
saveFiltersEnabled={saveFiltersEnabled}
|
||||
isLoadingFilters={isLoadingFilters}
|
||||
/>
|
||||
|
||||
{/* View Toggle */}
|
||||
<ViewToggle
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||
<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 +454,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 +468,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,35 +485,38 @@ 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>
|
||||
</div>
|
||||
) : (
|
||||
viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredScripts.map((script, index) => {
|
||||
// Add validation to ensure script has required properties
|
||||
@@ -428,6 +536,27 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredScripts.map((script, index) => {
|
||||
// Add validation to ensure script has required properties
|
||||
if (!script || typeof script !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||
|
||||
return (
|
||||
<ScriptCardList
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<ScriptDetailModal
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { CreateServerData } from '../../types/server';
|
||||
import { Button } from './ui/button';
|
||||
import { SSHKeyInput } from './SSHKeyInput';
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (data: CreateServerData) => void;
|
||||
@@ -17,13 +19,35 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
ip: '',
|
||||
user: '',
|
||||
password: '',
|
||||
auth_type: 'password',
|
||||
ssh_key: '',
|
||||
ssh_key_passphrase: '',
|
||||
ssh_port: 22,
|
||||
color: '#3b82f6',
|
||||
}
|
||||
);
|
||||
|
||||
const [errors, setErrors] = useState<Partial<CreateServerData>>({});
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
|
||||
const [sshKeyError, setSshKeyError] = useState<string>('');
|
||||
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadColorCodingSetting = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/color-coding');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setColorCodingEnabled(Boolean(data.enabled));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading color coding setting:', error);
|
||||
}
|
||||
};
|
||||
void loadColorCodingSetting();
|
||||
}, []);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<CreateServerData> = {};
|
||||
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Server name is required';
|
||||
@@ -43,12 +67,36 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
newErrors.user = 'Username is required';
|
||||
}
|
||||
|
||||
if (!formData.password.trim()) {
|
||||
newErrors.password = 'Password is required';
|
||||
// Validate SSH port
|
||||
if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
|
||||
newErrors.ssh_port = 'SSH port must be between 1 and 65535';
|
||||
}
|
||||
|
||||
// Validate authentication based on auth_type
|
||||
const authType = formData.auth_type ?? 'password';
|
||||
|
||||
if (authType === 'password' || authType === 'both') {
|
||||
if (!formData.password?.trim()) {
|
||||
newErrors.password = 'Password is required for password authentication';
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'key' || authType === 'both') {
|
||||
if (!formData.ssh_key?.trim()) {
|
||||
newErrors.ssh_key = 'SSH key is required for key authentication';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at least one authentication method is provided
|
||||
if (authType === 'both') {
|
||||
if (!formData.password?.trim() && !formData.ssh_key?.trim()) {
|
||||
newErrors.password = 'At least one authentication method (password or SSH key) is required';
|
||||
newErrors.ssh_key = 'At least one authentication method (password or SSH key) is required';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -56,13 +104,23 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
if (validateForm()) {
|
||||
onSubmit(formData);
|
||||
if (!isEditing) {
|
||||
setFormData({ name: '', ip: '', user: '', password: '' });
|
||||
setFormData({
|
||||
name: '',
|
||||
ip: '',
|
||||
user: '',
|
||||
password: '',
|
||||
auth_type: 'password',
|
||||
ssh_key: '',
|
||||
ssh_key_passphrase: '',
|
||||
ssh_port: 22,
|
||||
color: '#3b82f6'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof CreateServerData) => (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||
// Clear error when user starts typing
|
||||
@@ -71,11 +129,18 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
}
|
||||
};
|
||||
|
||||
const handleSSHKeyChange = (value: string) => {
|
||||
setFormData(prev => ({ ...prev, ssh_key: value }));
|
||||
if (errors.ssh_key) {
|
||||
setErrors(prev => ({ ...prev, ssh_key: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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 +148,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 +165,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,48 +182,145 @@ 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">
|
||||
Password *
|
||||
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
SSH Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="ssh_port"
|
||||
value={formData.ssh_port ?? 22}
|
||||
onChange={handleChange('ssh_port')}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||
errors.ssh_port ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="22"
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Authentication Type *
|
||||
</label>
|
||||
<select
|
||||
id="auth_type"
|
||||
value={formData.auth_type ?? 'password'}
|
||||
onChange={handleChange('auth_type')}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
>
|
||||
<option value="password">Password Only</option>
|
||||
<option value="key">SSH Key Only</option>
|
||||
<option value="both">Both Password & SSH Key</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{colorCodingEnabled && (
|
||||
<div>
|
||||
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Server Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
id="color"
|
||||
value={formData.color ?? '#3b82f6'}
|
||||
onChange={handleChange('color')}
|
||||
className="w-20 h-10 rounded cursor-pointer border border-border"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Choose a color to identify this server
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Authentication */}
|
||||
{(formData.auth_type === 'password' || formData.auth_type === 'both') && (
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={formData.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>
|
||||
)}
|
||||
|
||||
{/* SSH Key Authentication */}
|
||||
{(formData.auth_type === 'key' || formData.auth_type === 'both') && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
SSH Private Key {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
||||
</label>
|
||||
<SSHKeyInput
|
||||
value={formData.ssh_key ?? ''}
|
||||
onChange={handleSSHKeyChange}
|
||||
onError={setSshKeyError}
|
||||
/>
|
||||
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
||||
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<div>
|
||||
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
SSH Key Passphrase (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="ssh_key_passphrase"
|
||||
value={formData.ssh_key_passphrase ?? ''}
|
||||
onChange={handleChange('ssh_key_passphrase')}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
placeholder="Enter passphrase for encrypted key"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Only required if your SSH key is encrypted with a passphrase
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
||||
{isEditing && onCancel && (
|
||||
<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"
|
||||
className="w-full sm:w-auto order-2 sm:order-1"
|
||||
>
|
||||
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"
|
||||
className="w-full sm:w-auto order-1 sm:order-2"
|
||||
>
|
||||
{isEditing ? 'Update Server' : 'Add Server'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -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,16 +85,25 @@ 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"
|
||||
style={{ borderLeft: `4px solid ${server.color ?? 'transparent'}` }}
|
||||
>
|
||||
{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,
|
||||
ip: server.ip,
|
||||
user: server.user,
|
||||
password: server.password,
|
||||
auth_type: server.auth_type,
|
||||
ssh_key: server.ssh_key,
|
||||
ssh_key_passphrase: server.ssh_key_passphrase,
|
||||
ssh_port: server.ssh_port,
|
||||
color: server.color,
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
isEditing={true}
|
||||
@@ -101,33 +111,33 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between space-y-4 sm:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start sm:items-center space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 sm:w-6 sm:h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</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-base sm:text-lg font-medium text-foreground truncate">{server.name}</h3>
|
||||
<div className="mt-1 flex flex-col sm:flex-row sm:items-center space-y-1 sm:space-y-0 sm:space-x-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
|
||||
</svg>
|
||||
{server.ip}
|
||||
<span className="truncate">{server.ip}</span>
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{server.user}
|
||||
<span className="truncate">{server.user}</span>
|
||||
</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>
|
||||
@@ -161,46 +171,58 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<Button
|
||||
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="w-full sm:w-auto border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
|
||||
>
|
||||
{testingConnections.has(server.id) ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Testing...
|
||||
<span className="hidden sm:inline">Testing...</span>
|
||||
<span className="sm:hidden">Test...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
Test Connection
|
||||
<span className="hidden sm:inline">Test Connection</span>
|
||||
<span className="sm:hidden">Test</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
<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"
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<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
|
||||
<span className="hidden sm:inline">Edit</span>
|
||||
<span className="sm:hidden">✏️</span>
|
||||
</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="flex-1 sm:flex-none border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
<span className="hidden sm:inline">Delete</span>
|
||||
<span className="sm:hidden">🗑️</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
50
src/app/_components/ServerSettingsButton.tsx
Normal file
50
src/app/_components/ServerSettingsButton.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function ServerSettingsButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
Add and manage PVE Servers:
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center"
|
||||
title="Add PVE Server"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Manage PVE Servers
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { GeneralSettingsModal } from './GeneralSettingsModal';
|
||||
import { Button } from './ui/button';
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
export function SettingsButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -9,40 +11,22 @@ 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">
|
||||
Application Settings:
|
||||
</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"
|
||||
title="Add PVE Server"
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center"
|
||||
title="Open Settings"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Manage PVE Servers
|
||||
</button>
|
||||
<Settings className="w-5 h-5 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
<GeneralSettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -14,7 +15,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -31,7 +31,11 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
throw new Error('Failed to fetch servers');
|
||||
}
|
||||
const data = await response.json();
|
||||
setServers(data as Server[]);
|
||||
// Sort servers by name alphabetically
|
||||
const sortedServers = (data as Server[]).sort((a, b) =>
|
||||
(a.name ?? '').localeCompare(b.name ?? '')
|
||||
);
|
||||
setServers(sortedServers);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
@@ -98,76 +102,52 @@ 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 p-2 sm:p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-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-4 sm:p-6 border-b border-border">
|
||||
<h2 className="text-xl sm: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">
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8 px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('servers')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'servers'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
Server Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('general')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'general'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
||||
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div className="mb-4 p-3 sm:p-4 bg-destructive/10 border border-destructive rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400 dark:text-red-300" viewBox="0 0 20 20" fill="currentColor">
|
||||
<svg className="h-4 w-4 sm:h-5 sm:w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
||||
<div className="mt-2 text-sm text-red-700">{error}</div>
|
||||
<div className="ml-2 sm:ml-3 min-w-0 flex-1">
|
||||
<h3 className="text-xs sm:text-sm font-medium text-red-800">Error</h3>
|
||||
<div className="mt-1 sm:mt-2 text-xs sm:text-sm text-red-700 break-words">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'servers' && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4 sm: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-base sm:text-lg font-medium text-foreground mb-3 sm: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-base sm:text-lg font-medium text-foreground mb-3 sm: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>
|
||||
@@ -180,14 +160,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'general' && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">General Settings</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">General settings will be available in a future update.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
204
src/app/_components/SetupModal.tsx
Normal file
204
src/app/_components/SetupModal.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Toggle } from './ui/toggle';
|
||||
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface SetupModalProps {
|
||||
isOpen: boolean;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [enableAuth, setEnableAuth] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Only validate passwords if authentication is enabled
|
||||
if (enableAuth && password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: enableAuth ? username : undefined,
|
||||
password: enableAuth ? password : undefined,
|
||||
enabled: enableAuth
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// If authentication is enabled, automatically log in the user
|
||||
if (enableAuth) {
|
||||
const loginResponse = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (loginResponse.ok) {
|
||||
// Login successful, complete setup
|
||||
onComplete();
|
||||
} else {
|
||||
// Setup succeeded but login failed, still complete setup
|
||||
console.warn('Setup completed but auto-login failed');
|
||||
onComplete();
|
||||
}
|
||||
} else {
|
||||
// Authentication disabled, just complete setup
|
||||
onComplete();
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json() as { error: string };
|
||||
setError(errorData.error ?? 'Failed to setup authentication');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
setError('Failed to setup authentication');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-8 w-8 text-green-600" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Setup Authentication</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
Set up authentication to secure your application. This will be required for future access.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="setup-username" className="block text-sm font-medium text-foreground mb-2">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="setup-username"
|
||||
type="text"
|
||||
placeholder="Choose a username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required={enableAuth}
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="setup-password" className="block text-sm font-medium text-foreground mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="setup-password"
|
||||
type="password"
|
||||
placeholder="Choose a password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required={enableAuth}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm-password" className="block text-sm font-medium text-foreground mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required={enableAuth}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground mb-1">Enable Authentication</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{enableAuth
|
||||
? 'Authentication will be required on every page load'
|
||||
: 'Authentication will be optional (can be enabled later in settings)'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enableAuth}
|
||||
onCheckedChange={setEnableAuth}
|
||||
disabled={isLoading}
|
||||
label="Enable authentication"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-800 border border-red-200 rounded-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading ||
|
||||
(enableAuth && (!username.trim() || !password.trim() || !confirmPassword.trim()))
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? 'Setting Up...' : 'Complete Setup'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { Button } from './ui/button';
|
||||
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface TerminalProps {
|
||||
scriptPath: string;
|
||||
@@ -22,42 +24,129 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [mobileInput, setMobileInput] = useState('');
|
||||
const [showMobileInput, setShowMobileInput] = useState(false);
|
||||
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isStopped, setIsStopped] = useState(false);
|
||||
const [isTerminalReady, setIsTerminalReady] = useState(false);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<any>(null);
|
||||
const fitAddonRef = useRef<any>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [executionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||
const inputHandlerRef = useRef<((data: string) => void) | null>(null);
|
||||
const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||
const isConnectingRef = useRef<boolean>(false);
|
||||
const hasConnectedRef = useRef<boolean>(false);
|
||||
|
||||
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
|
||||
|
||||
const handleMessage = useCallback((message: TerminalMessage) => {
|
||||
if (!xtermRef.current) return;
|
||||
|
||||
const timestamp = new Date(message.timestamp).toLocaleTimeString();
|
||||
const prefix = `[${timestamp}] `;
|
||||
|
||||
switch (message.type) {
|
||||
case 'start':
|
||||
xtermRef.current.writeln(`${prefix}[START] ${message.data}`);
|
||||
setIsRunning(true);
|
||||
break;
|
||||
case 'output':
|
||||
// Write directly to terminal - xterm.js handles ANSI codes natively
|
||||
xtermRef.current.write(message.data);
|
||||
break;
|
||||
case 'error':
|
||||
// Check if this looks like ANSI terminal output (contains escape codes)
|
||||
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
|
||||
// This is likely terminal output sent to stderr, treat it as normal output
|
||||
xtermRef.current.write(message.data);
|
||||
} else if (message.data.includes('TERM environment variable not set')) {
|
||||
// This is a common warning, treat as normal output
|
||||
xtermRef.current.write(message.data);
|
||||
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
|
||||
// This is a script error, show it with error prefix
|
||||
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
|
||||
} else {
|
||||
// This is a real error, show it with error prefix
|
||||
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
|
||||
}
|
||||
break;
|
||||
case 'end':
|
||||
setIsRunning(false);
|
||||
|
||||
// Check if this is an LXC creation script
|
||||
const isLxcCreation = scriptPath.includes('ct/') ||
|
||||
scriptPath.includes('create_lxc') ||
|
||||
(containerId != null) ||
|
||||
scriptName.includes('lxc') ||
|
||||
scriptName.includes('container');
|
||||
|
||||
if (isLxcCreation && message.data.includes('SSH script execution finished with code: 0')) {
|
||||
// Display prominent LXC creation completion message
|
||||
xtermRef.current.writeln('');
|
||||
xtermRef.current.writeln('#########################################');
|
||||
xtermRef.current.writeln('########## LXC CREATION FINISHED ########');
|
||||
xtermRef.current.writeln('#########################################');
|
||||
xtermRef.current.writeln('');
|
||||
} else {
|
||||
xtermRef.current.writeln(`${prefix}✅ ${message.data}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [scriptPath, containerId, scriptName]);
|
||||
|
||||
// Ensure we're on the client side
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
// Detect mobile on mount
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only initialize on client side
|
||||
if (!isClient || !terminalRef.current || xtermRef.current) return;
|
||||
|
||||
// Store ref value to avoid stale closure
|
||||
const terminalElement = terminalRef.current;
|
||||
|
||||
// Use setTimeout to ensure DOM is fully ready
|
||||
const initTerminal = async () => {
|
||||
if (!terminalRef.current || xtermRef.current) return;
|
||||
if (!terminalElement || xtermRef.current) return;
|
||||
|
||||
// Dynamically import xterm modules to avoid SSR issues
|
||||
const { Terminal: XTerm } = await import('@xterm/xterm');
|
||||
const { FitAddon } = await import('@xterm/addon-fit');
|
||||
const { WebLinksAddon } = await import('@xterm/addon-web-links');
|
||||
|
||||
// Use the mobile state
|
||||
|
||||
const terminal = new XTerm({
|
||||
theme: {
|
||||
background: '#000000',
|
||||
foreground: '#00ff00',
|
||||
cursor: '#00ff00',
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#58a6ff',
|
||||
cursorAccent: '#0d1117',
|
||||
// Let ANSI colors work naturally - only define basic colors
|
||||
black: '#484f58',
|
||||
red: '#f85149',
|
||||
green: '#3fb950',
|
||||
yellow: '#d29922',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#39d353',
|
||||
white: '#b1bac4',
|
||||
brightBlack: '#6e7681',
|
||||
brightRed: '#ff7b72',
|
||||
brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#79c0ff',
|
||||
brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d364',
|
||||
brightWhite: '#f0f6fc',
|
||||
},
|
||||
fontSize: 14,
|
||||
fontFamily: 'Courier New, monospace',
|
||||
fontSize: isMobile ? 7 : 14,
|
||||
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
scrollback: 1000,
|
||||
@@ -68,6 +157,12 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
macOptionIsMeta: false,
|
||||
rightClickSelectsWord: false,
|
||||
wordSeparator: ' ()[]{}\'"`<>|',
|
||||
// Better ANSI handling
|
||||
allowProposedApi: true,
|
||||
// Force proper terminal behavior for interactive applications
|
||||
// Use smaller dimensions on mobile but ensure proper fit
|
||||
cols: isMobile ? 45 : 80,
|
||||
rows: isMobile ? 18 : 24,
|
||||
});
|
||||
|
||||
// Add addons
|
||||
@@ -76,40 +171,70 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
// Enable better ANSI handling
|
||||
terminal.options.allowProposedApi = true;
|
||||
|
||||
// Open terminal
|
||||
terminal.open(terminalRef.current);
|
||||
terminal.open(terminalElement);
|
||||
|
||||
// Ensure proper terminal rendering
|
||||
setTimeout(() => {
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
// Ensure cursor is properly positioned
|
||||
terminal.focus();
|
||||
|
||||
// Force focus on the terminal element
|
||||
terminalElement.focus();
|
||||
terminalElement.click();
|
||||
|
||||
// Add click handler to ensure terminal stays focused
|
||||
const focusHandler = () => {
|
||||
terminal.focus();
|
||||
terminalElement.focus();
|
||||
};
|
||||
terminalElement.addEventListener('click', focusHandler);
|
||||
|
||||
// Store the handler for cleanup
|
||||
(terminalElement as any).focusHandler = focusHandler;
|
||||
}, 100);
|
||||
|
||||
// Fit after a small delay to ensure proper sizing
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
// Force fit multiple times for mobile to ensure proper sizing
|
||||
if (isMobile) {
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 200);
|
||||
}, 300);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Store references
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// Handle terminal input
|
||||
terminal.onData((data) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
action: 'input',
|
||||
executionId,
|
||||
input: data
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle terminal resize
|
||||
// Add resize listener for mobile responsiveness
|
||||
const handleResize = () => {
|
||||
if (fitAddonRef.current) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current.fit();
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Store the handler for cleanup
|
||||
(terminalElement as any).resizeHandler = handleResize;
|
||||
|
||||
// Store references
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// Mark terminal as ready
|
||||
setIsTerminalReady(true);
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
terminal.dispose();
|
||||
};
|
||||
};
|
||||
@@ -121,13 +246,49 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (terminalElement && (terminalElement as any).resizeHandler) {
|
||||
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
|
||||
}
|
||||
if (terminalElement && (terminalElement as any).focusHandler) {
|
||||
terminalElement.removeEventListener('click', (terminalElement as any).focusHandler as (this: HTMLDivElement, ev: PointerEvent) => any);
|
||||
}
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
setIsTerminalReady(false);
|
||||
}
|
||||
};
|
||||
}, [executionId, isClient]);
|
||||
}, [isClient, isMobile]);
|
||||
|
||||
// Handle terminal input with current executionId
|
||||
useEffect(() => {
|
||||
if (!isTerminalReady || !xtermRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terminal = xtermRef.current;
|
||||
|
||||
const handleData = (data: string) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
action: 'input',
|
||||
executionId,
|
||||
input: data
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
|
||||
// Store the handler reference
|
||||
inputHandlerRef.current = handleData;
|
||||
terminal.onData(handleData);
|
||||
|
||||
return () => {
|
||||
// Clear the handler reference
|
||||
inputHandlerRef.current = null;
|
||||
};
|
||||
}, [executionId, isTerminalReady]); // Depend on terminal ready state
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent multiple connections in React Strict Mode
|
||||
@@ -142,6 +303,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
const isInitialConnection = !hasConnectedRef.current;
|
||||
hasConnectedRef.current = true;
|
||||
|
||||
// Small delay to prevent rapid reconnection
|
||||
@@ -157,17 +319,23 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
setIsConnected(true);
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Send start message immediately after connection
|
||||
// Only auto-start on initial connection, not on reconnections
|
||||
if (isInitialConnection && !isRunning) {
|
||||
// Generate a new execution ID for the initial run
|
||||
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
setExecutionId(newExecutionId);
|
||||
|
||||
const message = {
|
||||
action: 'start',
|
||||
scriptPath,
|
||||
executionId,
|
||||
executionId: newExecutionId,
|
||||
mode,
|
||||
server,
|
||||
isUpdate,
|
||||
containerId
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -204,52 +372,19 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [scriptPath, executionId, mode, server, isUpdate, containerId]);
|
||||
|
||||
const handleMessage = (message: TerminalMessage) => {
|
||||
if (!xtermRef.current) return;
|
||||
|
||||
const timestamp = new Date(message.timestamp).toLocaleTimeString();
|
||||
const prefix = `[${timestamp}] `;
|
||||
|
||||
switch (message.type) {
|
||||
case 'start':
|
||||
xtermRef.current.writeln(`${prefix}🚀 ${message.data}`);
|
||||
setIsRunning(true);
|
||||
break;
|
||||
case 'output':
|
||||
// Write directly to terminal - xterm.js handles ANSI codes natively
|
||||
xtermRef.current.write(message.data);
|
||||
break;
|
||||
case 'error':
|
||||
// Check if this looks like ANSI terminal output (contains escape codes)
|
||||
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
|
||||
// This is likely terminal output sent to stderr, treat it as normal output
|
||||
xtermRef.current.write(message.data);
|
||||
} else if (message.data.includes('TERM environment variable not set')) {
|
||||
// This is a common warning, treat as normal output
|
||||
xtermRef.current.write(message.data);
|
||||
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
|
||||
// This is a script error, show it with error prefix
|
||||
xtermRef.current.writeln(`${prefix}❌ ${message.data}`);
|
||||
} else {
|
||||
// This is a real error, show it with error prefix
|
||||
xtermRef.current.writeln(`${prefix}❌ ${message.data}`);
|
||||
}
|
||||
break;
|
||||
case 'end':
|
||||
xtermRef.current.writeln(`${prefix}✅ ${message.data}`);
|
||||
setIsRunning(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
}, [scriptPath, mode, server, isUpdate, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const startScript = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||
// Generate a new execution ID for each script run
|
||||
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
setExecutionId(newExecutionId);
|
||||
|
||||
setIsStopped(false);
|
||||
wsRef.current.send(JSON.stringify({
|
||||
action: 'start',
|
||||
scriptPath,
|
||||
executionId,
|
||||
executionId: newExecutionId,
|
||||
mode,
|
||||
server,
|
||||
isUpdate,
|
||||
@@ -260,6 +395,8 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
|
||||
const stopScript = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
setIsStopped(true);
|
||||
setIsRunning(false);
|
||||
wsRef.current.send(JSON.stringify({
|
||||
action: 'stop',
|
||||
executionId
|
||||
@@ -273,47 +410,71 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
}
|
||||
};
|
||||
|
||||
const sendInput = (input: string) => {
|
||||
setLastInputSent(input);
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
action: 'input',
|
||||
executionId,
|
||||
input: input
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
// Clear the feedback after 2 seconds
|
||||
setTimeout(() => setLastInputSent(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileInput = (input: string) => {
|
||||
sendInput(input);
|
||||
setMobileInput('');
|
||||
};
|
||||
|
||||
|
||||
const handleEnterKey = () => {
|
||||
sendInput('\r');
|
||||
};
|
||||
|
||||
// Don't render on server side
|
||||
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="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 className="bg-muted px-2 sm:px-4 py-2 flex items-center justify-between border-b border-border">
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
<div className="flex space-x-1 flex-shrink-0">
|
||||
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-red-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-yellow-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 sm:w-3 sm: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-xs sm:text-sm ml-1 sm:ml-2 truncate">
|
||||
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-1 sm:space-x-2 flex-shrink-0">
|
||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span className="text-gray-400 text-xs">
|
||||
<span className="text-muted-foreground text-xs hidden sm:inline">
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -322,51 +483,199 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="h-96 w-full"
|
||||
style={{ minHeight: '384px' }}
|
||||
className={`h-[16rem] sm:h-[24rem] lg:h-[32rem] w-full max-w-4xl mx-auto ${isMobile ? 'mobile-terminal' : ''}`}
|
||||
style={{
|
||||
minHeight: '256px'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Terminal Controls */}
|
||||
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-t border-gray-700">
|
||||
<div className="flex space-x-2">
|
||||
<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'
|
||||
}`}
|
||||
{/* Mobile Input Controls - Only show on mobile */}
|
||||
<div className="block sm:hidden bg-muted/50 px-2 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">Mobile Input</span>
|
||||
{lastInputSent && (
|
||||
<span className="text-xs text-green-500 bg-green-500/10 px-2 py-1 rounded">
|
||||
Sent: {lastInputSent === '\r' ? 'Enter' :
|
||||
lastInputSent === ' ' ? 'Space' :
|
||||
lastInputSent === '\b' ? 'Backspace' :
|
||||
lastInputSent === '\x1b[A' ? 'Up' :
|
||||
lastInputSent === '\x1b[B' ? 'Down' :
|
||||
lastInputSent === '\x1b[C' ? 'Right' :
|
||||
lastInputSent === '\x1b[D' ? 'Left' :
|
||||
lastInputSent}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowMobileInput(!showMobileInput)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
▶️ Start
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={stopScript}
|
||||
disabled={!isRunning}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
isRunning
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
⏹️ Stop
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={clearOutput}
|
||||
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
🗑️ Clear
|
||||
</button>
|
||||
<Keyboard className="h-4 w-4 mr-1" />
|
||||
{showMobileInput ? 'Hide' : 'Show'} Input
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
{showMobileInput && (
|
||||
<div className="space-y-3">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={() => sendInput('\x1b[A')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm flex items-center justify-center gap-2"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
Up
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => sendInput('\x1b[B')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm flex items-center justify-center gap-2"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Down
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Left/Right Navigation Buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={() => sendInput('\x1b[D')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm flex items-center justify-center gap-2"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Left
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => sendInput('\x1b[C')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm flex items-center justify-center gap-2"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
Right
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
onClick={handleEnterKey}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
Enter
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => sendInput(' ')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
Space
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => sendInput('\b')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
⌫ Backspace
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Custom Input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={mobileInput}
|
||||
onChange={(e) => setMobileInput(e.target.value)}
|
||||
placeholder="Type command..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleMobileInput(mobileInput);
|
||||
}
|
||||
}}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleMobileInput(mobileInput)}
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={!isConnected || !mobileInput.trim()}
|
||||
className="px-3"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal Controls */}
|
||||
<div className="bg-muted px-2 sm:px-4 py-2 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 border-t border-border">
|
||||
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||
<Button
|
||||
onClick={startScript}
|
||||
disabled={!isConnected || (isRunning && !isStopped)}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`text-xs sm:text-sm ${isConnected && (!isRunning || isStopped) ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||
>
|
||||
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
<span className="hidden sm:inline">Start</span>
|
||||
<span className="sm:hidden">▶</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={stopScript}
|
||||
disabled={!isRunning}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`text-xs sm:text-sm ${isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||
>
|
||||
<Square className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
<span className="hidden sm:inline">Stop</span>
|
||||
<span className="sm:hidden">⏹</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={clearOutput}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
<span className="sm:hidden">🗑</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm bg-gray-600 text-white hover:bg-gray-700 w-full sm:w-auto"
|
||||
>
|
||||
<X className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 mx-4 sm:mx-0">
|
||||
{/* 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">
|
||||
|
||||
312
src/app/_components/VersionDisplay.tsx
Normal file
312
src/app/_components/VersionDisplay.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
'use client';
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
// Loading overlay component with log streaming
|
||||
function LoadingOverlay({
|
||||
isNetworkError = false,
|
||||
logs = []
|
||||
}: {
|
||||
isNetworkError?: boolean;
|
||||
logs?: string[];
|
||||
}) {
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-card rounded-lg p-8 shadow-2xl border border-border max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-2">
|
||||
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isNetworkError
|
||||
? 'The server is restarting after the update...'
|
||||
: 'Please stand by while we update your application...'
|
||||
}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{isNetworkError
|
||||
? 'This may take a few moments. The page will reload automatically.'
|
||||
: 'The server will restart automatically when complete.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Log output */}
|
||||
{logs.length > 0 && (
|
||||
<div className="w-full mt-4 bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-60 overflow-y-auto terminal-output">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VersionDisplay() {
|
||||
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [isNetworkError, setIsNetworkError] = useState(false);
|
||||
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
|
||||
const [shouldSubscribe, setShouldSubscribe] = useState(false);
|
||||
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
||||
const lastLogTimeRef = useRef<number>(Date.now());
|
||||
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const executeUpdate = api.version.executeUpdate.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setUpdateResult({ success: result.success, message: result.message });
|
||||
|
||||
if (result.success) {
|
||||
// Start subscribing to update logs
|
||||
setShouldSubscribe(true);
|
||||
setUpdateLogs(['Update started...']);
|
||||
} else {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setUpdateResult({ success: false, message: error.message });
|
||||
setIsUpdating(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Poll for update logs
|
||||
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
|
||||
enabled: shouldSubscribe,
|
||||
refetchInterval: 1000, // Poll every second
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
// Update logs when data changes
|
||||
useEffect(() => {
|
||||
if (updateLogsData?.success && updateLogsData.logs) {
|
||||
lastLogTimeRef.current = Date.now();
|
||||
setUpdateLogs(updateLogsData.logs);
|
||||
|
||||
if (updateLogsData.isComplete) {
|
||||
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
||||
setIsNetworkError(true);
|
||||
// Start reconnection attempts when we know update is complete
|
||||
startReconnectAttempts();
|
||||
}
|
||||
}
|
||||
}, [updateLogsData]);
|
||||
|
||||
// Monitor for server connection loss and auto-reload (fallback only)
|
||||
useEffect(() => {
|
||||
if (!shouldSubscribe) return;
|
||||
|
||||
// Only use this as a fallback - the main trigger should be completion detection
|
||||
const checkInterval = setInterval(() => {
|
||||
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
|
||||
|
||||
// Only start reconnection if we've been updating for at least 3 minutes
|
||||
// and no logs for 60 seconds (very conservative fallback)
|
||||
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
||||
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
||||
|
||||
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
|
||||
console.log('Fallback: Assuming server restart due to long silence');
|
||||
setIsNetworkError(true);
|
||||
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
||||
|
||||
// Start trying to reconnect
|
||||
startReconnectAttempts();
|
||||
}
|
||||
}, 10000); // Check every 10 seconds
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
|
||||
|
||||
// Attempt to reconnect and reload page when server is back
|
||||
const startReconnectAttempts = () => {
|
||||
if (reconnectIntervalRef.current) return;
|
||||
|
||||
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
|
||||
|
||||
reconnectIntervalRef.current = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
// Try to fetch the root path to check if server is back
|
||||
const response = await fetch('/', { method: 'HEAD' });
|
||||
if (response.ok || response.status === 200) {
|
||||
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
|
||||
|
||||
// Clear interval and reload
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
} catch {
|
||||
// Server still down, keep trying
|
||||
}
|
||||
})();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Cleanup reconnect interval on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUpdate = () => {
|
||||
setIsUpdating(true);
|
||||
setUpdateResult(null);
|
||||
setIsNetworkError(false);
|
||||
setUpdateLogs([]);
|
||||
setShouldSubscribe(false);
|
||||
setUpdateStartTime(Date.now());
|
||||
lastLogTimeRef.current = Date.now();
|
||||
executeUpdate.mutate();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="animate-pulse">
|
||||
Loading...
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !versionStatus?.success) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
v{versionStatus?.currentVersion ?? 'Unknown'}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(Unable to check for updates)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { currentVersion, isUpToDate, updateAvailable, releaseInfo } = versionStatus;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Loading overlay */}
|
||||
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
||||
<Badge variant={isUpToDate ? "default" : "secondary"} className="text-xs">
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
|
||||
{updateAvailable && releaseInfo && (
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
|
||||
<div className="relative group">
|
||||
<Badge variant="destructive" className="animate-pulse cursor-help text-xs">
|
||||
Update Available
|
||||
</Badge>
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10 hidden sm:block">
|
||||
<div className="text-center">
|
||||
<div className="font-semibold mb-1">How to update:</div>
|
||||
<div>Click the button to update, when installed via the helper script</div>
|
||||
<div>or update manually:</div>
|
||||
<div>cd $PVESCRIPTLOCAL_DIR</div>
|
||||
<div>git pull</div>
|
||||
<div>npm install</div>
|
||||
<div>npm run build</div>
|
||||
<div>npm start</div>
|
||||
</div>
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
disabled={isUpdating}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
<span className="hidden sm:inline">Updating...</span>
|
||||
<span className="sm:hidden">...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">Update Now</span>
|
||||
<span className="sm:hidden">Update</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<a
|
||||
href={releaseInfo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="View latest release"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{updateResult && (
|
||||
<div className={`text-xs px-2 py-1 rounded text-center ${
|
||||
updateResult.success
|
||||
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
|
||||
: 'bg-destructive/20 text-destructive border border-destructive/30'
|
||||
}`}>
|
||||
{updateResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUpToDate && (
|
||||
<span className="text-xs text-chart-2">
|
||||
✓ Up to date
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
src/app/_components/ViewToggle.tsx
Normal file
45
src/app/_components/ViewToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Grid3X3, List } from 'lucide-react';
|
||||
|
||||
interface ViewToggleProps {
|
||||
viewMode: 'card' | 'list';
|
||||
onViewModeChange: (mode: 'card' | 'list') => void;
|
||||
}
|
||||
|
||||
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
|
||||
return (
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="flex items-center space-x-1 bg-muted rounded-lg p-1">
|
||||
<Button
|
||||
onClick={() => onViewModeChange('card')}
|
||||
variant={viewMode === 'card' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className={`flex items-center space-x-2 ${
|
||||
viewMode === 'card'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
<span className="text-sm">Card View</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onViewModeChange('list')}
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className={`flex items-center space-x-2 ${
|
||||
viewMode === 'list'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
<span className="text-sm">List View</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/_components/ui/badge.tsx
Normal file
28
src/app/_components/ui/badge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "secondary" | "destructive" | "outline"
|
||||
}
|
||||
|
||||
function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
const variantClasses = {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge }
|
||||
109
src/app/_components/ui/button.tsx
Normal file
109
src/app/_components/ui/button.tsx
Normal 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 };
|
||||
23
src/app/_components/ui/input.tsx
Normal file
23
src/app/_components/ui/input.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../../lib/utils"
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
41
src/app/_components/ui/toggle.tsx
Normal file
41
src/app/_components/ui/toggle.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../../lib/utils"
|
||||
|
||||
export interface ToggleProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
|
||||
({ className, checked, onCheckedChange, label, ...props }, ref) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<div className={cn(
|
||||
"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out peer-checked:bg-blue-600 transition-colors duration-300 ease-in-out",
|
||||
checked && "bg-blue-600 after:translate-x-full",
|
||||
className
|
||||
)} />
|
||||
</label>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Toggle.displayName = "Toggle"
|
||||
|
||||
export { Toggle }
|
||||
66
src/app/api/auth/login/route.ts
Normal file
66
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { comparePassword, generateToken, getAuthConfig } from '~/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { username, password } = await request.json() as { username: string; password: string };
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const authConfig = getAuthConfig();
|
||||
|
||||
if (!authConfig.hasCredentials) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication not configured' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (username !== authConfig.username) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const isValidPassword = await comparePassword(password, authConfig.passwordHash!);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const token = generateToken(username);
|
||||
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
username
|
||||
});
|
||||
|
||||
// Set httpOnly cookie
|
||||
response.cookies.set('auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error during login:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
94
src/app/api/auth/setup/route.ts
Normal file
94
src/app/api/auth/setup/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { updateAuthCredentials, getAuthConfig, setSetupCompleted } from '~/lib/auth';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { username, password, enabled } = await request.json() as { username?: string; password?: string; enabled?: boolean };
|
||||
|
||||
// If authentication is disabled, we don't need any credentials
|
||||
if (enabled === false) {
|
||||
// Just set AUTH_ENABLED to false without storing credentials
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Update or add AUTH_ENABLED
|
||||
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||
if (enabledRegex.test(envContent)) {
|
||||
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
|
||||
}
|
||||
|
||||
// Set setup completed flag
|
||||
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
|
||||
if (setupCompletedRegex.test(envContent)) {
|
||||
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
|
||||
}
|
||||
|
||||
// Clean up any empty AUTH_USERNAME or AUTH_PASSWORD_HASH lines
|
||||
envContent = envContent.replace(/^AUTH_USERNAME=\s*$/m, '');
|
||||
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=\s*$/m, '');
|
||||
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Authentication disabled successfully'
|
||||
});
|
||||
}
|
||||
|
||||
// If authentication is enabled, require username and password
|
||||
if (!username) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username is required when authentication is enabled' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username must be at least 3 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!password || password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 6 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if credentials already exist
|
||||
const authConfig = getAuthConfig();
|
||||
if (authConfig.hasCredentials) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication is already configured' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await updateAuthCredentials(username, password, enabled ?? true);
|
||||
setSetupCompleted();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Authentication setup completed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during setup:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/api/auth/verify/route.ts
Normal file
37
src/app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { verifyToken } from '~/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.cookies.get('auth-token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No token provided' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
if (!decoded) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid token' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
username: decoded.username,
|
||||
authenticated: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error verifying token:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,16 +52,55 @@ export async function PUT(
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, ip, user, password }: CreateServerData = body;
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !ip || !user || !password) {
|
||||
if (!name || !ip || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate SSH port
|
||||
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SSH port must be between 1 and 65535' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate authentication based on auth_type
|
||||
const authType = auth_type ?? 'password';
|
||||
|
||||
if (authType === 'password' || authType === 'both') {
|
||||
if (!password?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password is required for password authentication' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'key' || authType === 'both') {
|
||||
if (!ssh_key?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SSH key is required for key authentication' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at least one authentication method is provided
|
||||
if (authType === 'both') {
|
||||
if (!password?.trim() && !ssh_key?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'At least one authentication method (password or SSH key) is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
// Check if server exists
|
||||
@@ -73,7 +112,17 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
|
||||
const result = db.updateServer(id, { name, ip, user, password });
|
||||
const result = db.updateServer(id, {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
password,
|
||||
auth_type: authType,
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
color
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -20,18 +20,67 @@ export async function GET() {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, ip, user, password }: CreateServerData = body;
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !ip || !user || !password) {
|
||||
if (!name || !ip || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate SSH port
|
||||
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SSH port must be between 1 and 65535' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate authentication based on auth_type
|
||||
const authType = auth_type ?? 'password';
|
||||
|
||||
if (authType === 'password' || authType === 'both') {
|
||||
if (!password?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password is required for password authentication' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'key' || authType === 'both') {
|
||||
if (!ssh_key?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SSH key is required for key authentication' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at least one authentication method is provided
|
||||
if (authType === 'both') {
|
||||
if (!password?.trim() && !ssh_key?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'At least one authentication method (password or SSH key) is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const result = db.createServer({ name, ip, user, password });
|
||||
const result = db.createServer({
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
password,
|
||||
auth_type: authType,
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
color
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
117
src/app/api/settings/auth-credentials/route.ts
Normal file
117
src/app/api/settings/auth-credentials/route.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const authConfig = getAuthConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
username: authConfig.username,
|
||||
enabled: authConfig.enabled,
|
||||
hasCredentials: authConfig.hasCredentials,
|
||||
setupCompleted: authConfig.setupCompleted,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading auth credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read auth configuration' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { username, password, enabled } = await request.json() as { username: string; password: string; enabled?: boolean };
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username must be at least 3 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 6 characters long' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await updateAuthCredentials(username, password, enabled ?? false);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Authentication credentials updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating auth credentials:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update auth credentials' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const { enabled } = await request.json() as { enabled: boolean };
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enabled flag must be a boolean' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// When enabling, just update the flag
|
||||
updateAuthEnabled(enabled);
|
||||
} else {
|
||||
// When disabling, clear all credentials and set flag to false
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH
|
||||
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, '');
|
||||
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, '');
|
||||
|
||||
// Update or add AUTH_ENABLED
|
||||
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||
if (enabledRegex.test(envContent)) {
|
||||
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
|
||||
}
|
||||
|
||||
// Clean up empty lines
|
||||
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating auth enabled status:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update auth status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/settings/color-coding/route.ts
Normal file
75
src/app/api/settings/color-coding/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { enabled } = await request.json();
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enabled must be a boolean value' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Check if SERVER_COLOR_CODING_ENABLED already exists
|
||||
const colorCodingRegex = /^SERVER_COLOR_CODING_ENABLED=.*$/m;
|
||||
const colorCodingMatch = colorCodingRegex.exec(envContent);
|
||||
|
||||
if (colorCodingMatch) {
|
||||
// Replace existing SERVER_COLOR_CODING_ENABLED
|
||||
envContent = envContent.replace(colorCodingRegex, `SERVER_COLOR_CODING_ENABLED=${enabled}`);
|
||||
} else {
|
||||
// Add new SERVER_COLOR_CODING_ENABLED
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `SERVER_COLOR_CODING_ENABLED=${enabled}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Color coding setting saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error saving color coding setting:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save color coding setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return NextResponse.json({ enabled: false });
|
||||
}
|
||||
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
|
||||
// Extract SERVER_COLOR_CODING_ENABLED
|
||||
const colorCodingRegex = /^SERVER_COLOR_CODING_ENABLED=(.*)$/m;
|
||||
const colorCodingMatch = colorCodingRegex.exec(envContent);
|
||||
const enabled = colorCodingMatch ? colorCodingMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||
|
||||
return NextResponse.json({ enabled });
|
||||
} catch (error) {
|
||||
console.error('Error reading color coding setting:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read color coding setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
148
src/app/api/settings/filters/route.ts
Normal file
148
src/app/api/settings/filters/route.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { filters } = await request.json();
|
||||
|
||||
if (!filters || typeof filters !== 'object') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Filters object is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate filter structure
|
||||
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in filters)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Missing required field: ${field}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Serialize filters to JSON string
|
||||
const filtersJson = JSON.stringify(filters);
|
||||
|
||||
// Check if FILTERS already exists
|
||||
const filtersRegex = /^FILTERS=.*$/m;
|
||||
const filtersMatch = filtersRegex.exec(envContent);
|
||||
|
||||
if (filtersMatch) {
|
||||
// Replace existing FILTERS
|
||||
envContent = envContent.replace(filtersRegex, `FILTERS=${filtersJson}`);
|
||||
} else {
|
||||
// Add new FILTERS
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `FILTERS=${filtersJson}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Filters saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error saving filters:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save filters' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return NextResponse.json({ filters: null });
|
||||
}
|
||||
|
||||
// Read .env file and extract FILTERS
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
const filtersRegex = /^FILTERS=(.*)$/m;
|
||||
const filtersMatch = filtersRegex.exec(envContent);
|
||||
|
||||
if (!filtersMatch) {
|
||||
return NextResponse.json({ filters: null });
|
||||
}
|
||||
|
||||
try {
|
||||
const filtersJson = filtersMatch[1]?.trim();
|
||||
|
||||
// Check if filters JSON is empty or invalid
|
||||
if (!filtersJson || filtersJson === '') {
|
||||
return NextResponse.json({ filters: null });
|
||||
}
|
||||
|
||||
const filters = JSON.parse(filtersJson);
|
||||
|
||||
// Validate the parsed filters
|
||||
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
|
||||
const isValid = requiredFields.every(field => field in filters);
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ filters: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({ filters });
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing saved filters:', parseError);
|
||||
return NextResponse.json({ filters: null });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading filters:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read filters' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return NextResponse.json({ success: true, message: 'No filters to clear' });
|
||||
}
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = fs.readFileSync(envPath, 'utf8');
|
||||
|
||||
// Remove FILTERS line
|
||||
const filtersRegex = /^FILTERS=.*$/m;
|
||||
const filtersMatch = filtersRegex.exec(envContent);
|
||||
if (filtersMatch) {
|
||||
envContent = envContent.replace(filtersRegex, '');
|
||||
}
|
||||
|
||||
// Clean up extra newlines
|
||||
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Filters cleared successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error clearing filters:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to clear filters' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/settings/github-token/route.ts
Normal file
75
src/app/api/settings/github-token/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { token } = await request.json();
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token is required and must be a string' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Check if GITHUB_TOKEN already exists
|
||||
const githubTokenRegex = /^GITHUB_TOKEN=.*$/m;
|
||||
const githubTokenMatch = githubTokenRegex.exec(envContent);
|
||||
|
||||
if (githubTokenMatch) {
|
||||
// Replace existing GITHUB_TOKEN
|
||||
envContent = envContent.replace(githubTokenRegex, `GITHUB_TOKEN=${token}`);
|
||||
} else {
|
||||
// Add new GITHUB_TOKEN
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `GITHUB_TOKEN=${token}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'GitHub token saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error saving GitHub token:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save GitHub token' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return NextResponse.json({ token: null });
|
||||
}
|
||||
|
||||
// Read .env file and extract GITHUB_TOKEN
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
const githubTokenRegex = /^GITHUB_TOKEN=(.*)$/m;
|
||||
const githubTokenMatch = githubTokenRegex.exec(envContent);
|
||||
|
||||
const token = githubTokenMatch ? githubTokenMatch[1] : null;
|
||||
|
||||
return NextResponse.json({ token });
|
||||
} catch (error) {
|
||||
console.error('Error reading GitHub token:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read GitHub token' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/settings/save-filter/route.ts
Normal file
75
src/app/api/settings/save-filter/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { enabled } = await request.json();
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enabled value must be a boolean' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Check if SAVE_FILTER already exists
|
||||
const saveFilterRegex = /^SAVE_FILTER=.*$/m;
|
||||
const saveFilterMatch = saveFilterRegex.exec(envContent);
|
||||
|
||||
if (saveFilterMatch) {
|
||||
// Replace existing SAVE_FILTER
|
||||
envContent = envContent.replace(saveFilterRegex, `SAVE_FILTER=${enabled}`);
|
||||
} else {
|
||||
// Add new SAVE_FILTER
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `SAVE_FILTER=${enabled}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Save filter setting saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error saving save filter setting:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save save filter setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return NextResponse.json({ enabled: false });
|
||||
}
|
||||
|
||||
// Read .env file and extract SAVE_FILTER
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
const saveFilterRegex = /^SAVE_FILTER=(.*)$/m;
|
||||
const saveFilterMatch = saveFilterRegex.exec(envContent);
|
||||
|
||||
const enabled = saveFilterMatch ? saveFilterMatch[1] === 'true' : false;
|
||||
|
||||
return NextResponse.json({ enabled });
|
||||
} catch (error) {
|
||||
console.error('Error reading save filter setting:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read save filter setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
81
src/app/api/settings/view-mode/route.ts
Normal file
81
src/app/api/settings/view-mode/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { viewMode } = await request.json();
|
||||
|
||||
if (!viewMode || !['card', 'list'].includes(viewMode as string)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'View mode must be either "card" or "list"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Check if VIEW_MODE already exists
|
||||
const viewModeRegex = /^VIEW_MODE=.*$/m;
|
||||
const viewModeMatch = viewModeRegex.exec(envContent);
|
||||
|
||||
if (viewModeMatch) {
|
||||
// Replace existing VIEW_MODE
|
||||
envContent = envContent.replace(viewModeRegex, `VIEW_MODE=${viewMode}`);
|
||||
} else {
|
||||
// Add new VIEW_MODE
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `VIEW_MODE=${viewMode}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'View mode saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error saving view mode:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save view mode' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||
}
|
||||
|
||||
// Read .env file and extract VIEW_MODE
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
const viewModeRegex = /^VIEW_MODE=(.*)$/m;
|
||||
const viewModeMatch = viewModeRegex.exec(envContent);
|
||||
|
||||
if (!viewModeMatch) {
|
||||
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||
}
|
||||
|
||||
const viewMode = viewModeMatch[1]?.trim();
|
||||
|
||||
// Validate the view mode
|
||||
if (!viewMode || !['card', 'list'].includes(viewMode)) {
|
||||
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||
}
|
||||
|
||||
return NextResponse.json({ viewMode });
|
||||
} catch (error) {
|
||||
console.error('Error reading view mode:', error);
|
||||
return NextResponse.json({ viewMode: 'card' }); // Default to card view
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import "~/styles/globals.css";
|
||||
|
||||
import { type Metadata } from "next";
|
||||
import { type Metadata, type Viewport } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
import { DarkModeProvider } from "./_components/DarkModeProvider";
|
||||
import { DarkModeToggle } from "./_components/DarkModeToggle";
|
||||
import { AuthProvider } from "./_components/AuthProvider";
|
||||
import { AuthGuard } from "./_components/AuthGuard";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PVE Scripts local",
|
||||
description: "",
|
||||
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
|
||||
icons: [
|
||||
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
||||
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
||||
@@ -17,56 +17,43 @@ export const metadata: Metadata = {
|
||||
],
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
const geist = Geist({
|
||||
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) {
|
||||
// Force dark mode
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to system preference if localStorage fails
|
||||
const systemDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (systemDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<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>
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
{children}
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
</TRPCReactProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
151
src/app/page.tsx
151
src/app/page.tsx
@@ -1,19 +1,69 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } 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 { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||
import { SettingsButton } from './_components/SettingsButton';
|
||||
import { VersionDisplay } from './_components/VersionDisplay';
|
||||
import { Button } from './_components/ui/button';
|
||||
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { api } from '~/trpc/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 terminalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch data for script counts
|
||||
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData } = api.scripts.getCtScripts.useQuery();
|
||||
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
|
||||
// Calculate script counts
|
||||
const scriptCounts = {
|
||||
available: scriptCardsData?.success ? scriptCardsData.cards?.length ?? 0 : 0,
|
||||
downloaded: (() => {
|
||||
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
||||
|
||||
// Count scripts that are both in GitHub data and have local versions
|
||||
const githubScripts = scriptCardsData.cards ?? [];
|
||||
const localScripts = localScriptsData.scripts ?? [];
|
||||
|
||||
return githubScripts.filter(script => {
|
||||
if (!script?.name) return false;
|
||||
return localScripts.some(local => {
|
||||
if (!local?.name) return false;
|
||||
const localName = local.name.replace(/\.sh$/, '');
|
||||
return localName.toLowerCase() === script.name.toLowerCase() ||
|
||||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
|
||||
});
|
||||
}).length;
|
||||
})(),
|
||||
installed: installedScriptsData?.scripts?.length ?? 0
|
||||
};
|
||||
|
||||
const scrollToTerminal = () => {
|
||||
if (terminalRef.current) {
|
||||
// Get the element's position and scroll with a small offset for better mobile experience
|
||||
const elementTop = terminalRef.current.offsetTop;
|
||||
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
|
||||
|
||||
window.scrollTo({
|
||||
top: elementTop - offset,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
|
||||
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
|
||||
// Scroll to terminal after a short delay to ensure it's rendered
|
||||
setTimeout(scrollToTerminal, 100);
|
||||
};
|
||||
|
||||
const handleCloseTerminal = () => {
|
||||
@@ -21,54 +71,83 @@ export default function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-2 sm:px-4 py-4 sm: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
|
||||
<div className="text-center mb-6 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-2 sm:gap-3">
|
||||
<Rocket className="h-6 w-6 sm:h-8 w-8 lg:h-9 lg:w-9" />
|
||||
<span className="break-words">PVE Scripts Management</span>
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
|
||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||
</p>
|
||||
<div className="flex justify-center px-2">
|
||||
<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 sm:flex-row sm:items-center gap-4">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||
<ServerSettingsButton />
|
||||
<SettingsButton />
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<ResyncButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-8">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="border-b border-border">
|
||||
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 lg:space-x-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('scripts')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'scripts'
|
||||
? '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" />
|
||||
<span className="hidden sm:inline">Available Scripts</span>
|
||||
<span className="sm:hidden">Available</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
{scriptCounts.available}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('downloaded')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'downloaded'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent hover:text-accent-foreground'
|
||||
}`}>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Downloaded Scripts</span>
|
||||
<span className="sm:hidden">Downloaded</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
{scriptCounts.downloaded}
|
||||
</span>
|
||||
</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-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
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" />
|
||||
<span className="hidden sm:inline">Installed Scripts</span>
|
||||
<span className="sm:hidden">Installed</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
{scriptCounts.installed}
|
||||
</span>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +156,7 @@ export default function Home() {
|
||||
|
||||
{/* Running Script Terminal */}
|
||||
{runningScript && (
|
||||
<div className="mb-8">
|
||||
<div ref={terminalRef} className="mb-8">
|
||||
<Terminal
|
||||
scriptPath={runningScript.path}
|
||||
onClose={handleCloseTerminal}
|
||||
@@ -92,6 +171,10 @@ export default function Home() {
|
||||
<ScriptsGrid onInstallScript={handleRunScript} />
|
||||
)}
|
||||
|
||||
{activeTab === 'downloaded' && (
|
||||
<DownloadedScriptsTab onInstallScript={handleRunScript} />
|
||||
)}
|
||||
|
||||
{activeTab === 'installed' && (
|
||||
<InstalledScriptsTab />
|
||||
)}
|
||||
|
||||
20
src/env.js
20
src/env.js
@@ -23,6 +23,16 @@ export const env = createEnv({
|
||||
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
|
||||
// WebSocket Configuration
|
||||
WEBSOCKET_PORT: z.string().default("3001"),
|
||||
// GitHub Configuration
|
||||
GITHUB_TOKEN: z.string().optional(),
|
||||
// Authentication Configuration
|
||||
AUTH_USERNAME: z.string().optional(),
|
||||
AUTH_PASSWORD_HASH: z.string().optional(),
|
||||
AUTH_ENABLED: z.string().optional(),
|
||||
AUTH_SETUP_COMPLETED: z.string().optional(),
|
||||
JWT_SECRET: z.string().optional(),
|
||||
// Server Color Coding Configuration
|
||||
SERVER_COLOR_CODING_ENABLED: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -52,6 +62,16 @@ export const env = createEnv({
|
||||
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
|
||||
// WebSocket Configuration
|
||||
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
|
||||
// GitHub Configuration
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||
// Authentication Configuration
|
||||
AUTH_USERNAME: process.env.AUTH_USERNAME,
|
||||
AUTH_PASSWORD_HASH: process.env.AUTH_PASSWORD_HASH,
|
||||
AUTH_ENABLED: process.env.AUTH_ENABLED,
|
||||
AUTH_SETUP_COMPLETED: process.env.AUTH_SETUP_COMPLETED,
|
||||
JWT_SECRET: process.env.JWT_SECRET,
|
||||
// Server Color Coding Configuration
|
||||
SERVER_COLOR_CODING_ENABLED: process.env.SERVER_COLOR_CODING_ENABLED,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
/**
|
||||
|
||||
240
src/lib/auth.ts
Normal file
240
src/lib/auth.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { randomBytes } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
const JWT_EXPIRY = '7d'; // 7 days
|
||||
|
||||
// Cache for JWT secret to avoid multiple file reads
|
||||
let jwtSecretCache: string | null = null;
|
||||
|
||||
/**
|
||||
* Get or generate JWT secret
|
||||
*/
|
||||
export function getJwtSecret(): string {
|
||||
// Return cached secret if available
|
||||
if (jwtSecretCache) {
|
||||
return jwtSecretCache;
|
||||
}
|
||||
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Check if JWT_SECRET already exists
|
||||
const jwtSecretRegex = /^JWT_SECRET=(.*)$/m;
|
||||
const jwtSecretMatch = jwtSecretRegex.exec(envContent);
|
||||
|
||||
if (jwtSecretMatch?.[1]?.trim()) {
|
||||
jwtSecretCache = jwtSecretMatch[1].trim();
|
||||
return jwtSecretCache;
|
||||
}
|
||||
|
||||
// Generate new secret
|
||||
const newSecret = randomBytes(64).toString('hex');
|
||||
|
||||
// Add to .env file
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `JWT_SECRET=${newSecret}\n`;
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
// Cache the new secret
|
||||
jwtSecretCache = newSecret;
|
||||
|
||||
return newSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a password with a hash
|
||||
*/
|
||||
export async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token
|
||||
*/
|
||||
export function generateToken(username: string): string {
|
||||
const secret = getJwtSecret();
|
||||
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*/
|
||||
export function verifyToken(token: string): { username: string } | null {
|
||||
try {
|
||||
const secret = getJwtSecret();
|
||||
const decoded = jwt.verify(token, secret) as { username: string };
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read auth configuration from .env
|
||||
*/
|
||||
export function getAuthConfig(): {
|
||||
username: string | null;
|
||||
passwordHash: string | null;
|
||||
enabled: boolean;
|
||||
hasCredentials: boolean;
|
||||
setupCompleted: boolean;
|
||||
} {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return {
|
||||
username: null,
|
||||
passwordHash: null,
|
||||
enabled: false,
|
||||
hasCredentials: false,
|
||||
setupCompleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
|
||||
// Extract AUTH_USERNAME
|
||||
const usernameRegex = /^AUTH_USERNAME=(.*)$/m;
|
||||
const usernameMatch = usernameRegex.exec(envContent);
|
||||
const username = usernameMatch ? usernameMatch[1]?.trim() : null;
|
||||
|
||||
// Extract AUTH_PASSWORD_HASH
|
||||
const passwordHashRegex = /^AUTH_PASSWORD_HASH=(.*)$/m;
|
||||
const passwordHashMatch = passwordHashRegex.exec(envContent);
|
||||
const passwordHash = passwordHashMatch ? passwordHashMatch[1]?.trim() : null;
|
||||
|
||||
// Extract AUTH_ENABLED
|
||||
const enabledRegex = /^AUTH_ENABLED=(.*)$/m;
|
||||
const enabledMatch = enabledRegex.exec(envContent);
|
||||
const enabled = enabledMatch ? enabledMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||
|
||||
// Extract AUTH_SETUP_COMPLETED
|
||||
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=(.*)$/m;
|
||||
const setupCompletedMatch = setupCompletedRegex.exec(envContent);
|
||||
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||
|
||||
const hasCredentials = !!(username && passwordHash);
|
||||
|
||||
return {
|
||||
username: username ?? null,
|
||||
passwordHash: passwordHash ?? null,
|
||||
enabled,
|
||||
hasCredentials,
|
||||
setupCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update auth credentials in .env
|
||||
*/
|
||||
export async function updateAuthCredentials(
|
||||
username: string,
|
||||
password?: string,
|
||||
enabled?: boolean
|
||||
): Promise<void> {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Hash the password if provided
|
||||
const passwordHash = password ? await hashPassword(password) : null;
|
||||
|
||||
// Update or add AUTH_USERNAME
|
||||
const usernameRegex = /^AUTH_USERNAME=.*$/m;
|
||||
if (usernameRegex.test(envContent)) {
|
||||
envContent = envContent.replace(usernameRegex, `AUTH_USERNAME=${username}`);
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_USERNAME=${username}\n`;
|
||||
}
|
||||
|
||||
// Update or add AUTH_PASSWORD_HASH only if password is provided
|
||||
if (passwordHash) {
|
||||
const passwordHashRegex = /^AUTH_PASSWORD_HASH=.*$/m;
|
||||
if (passwordHashRegex.test(envContent)) {
|
||||
envContent = envContent.replace(passwordHashRegex, `AUTH_PASSWORD_HASH=${passwordHash}`);
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_PASSWORD_HASH=${passwordHash}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update or add AUTH_ENABLED if provided
|
||||
if (enabled !== undefined) {
|
||||
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||
if (enabledRegex.test(envContent)) {
|
||||
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set AUTH_SETUP_COMPLETED flag in .env
|
||||
*/
|
||||
export function setSetupCompleted(): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Update or add AUTH_SETUP_COMPLETED
|
||||
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
|
||||
if (setupCompletedRegex.test(envContent)) {
|
||||
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update AUTH_ENABLED flag in .env
|
||||
*/
|
||||
export function updateAuthEnabled(enabled: boolean): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Update or add AUTH_ENABLED
|
||||
const enabledRegex = /^AUTH_ENABLED=.*$/m;
|
||||
if (enabledRegex.test(envContent)) {
|
||||
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
}
|
||||
|
||||
35
src/lib/colorUtils.ts
Normal file
35
src/lib/colorUtils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Calculate the appropriate text color (black or white) for a given background color
|
||||
* to ensure optimal readability based on luminance
|
||||
*/
|
||||
export function getContrastColor(hexColor: string): 'black' | 'white' {
|
||||
if (!hexColor || hexColor.length !== 7 || !hexColor.startsWith('#')) {
|
||||
return 'black'; // Default to black for invalid colors
|
||||
}
|
||||
|
||||
// Remove the # and convert to RGB
|
||||
const r = parseInt(hexColor.slice(1, 3), 16);
|
||||
const g = parseInt(hexColor.slice(3, 5), 16);
|
||||
const b = parseInt(hexColor.slice(5, 7), 16);
|
||||
|
||||
// Calculate relative luminance using the standard formula
|
||||
// https://www.w3.org/WAI/GL/wiki/Relative_luminance
|
||||
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
|
||||
// Return black for light backgrounds, white for dark backgrounds
|
||||
return luminance > 0.5 ? 'black' : 'white';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a color string is a valid hex color
|
||||
*/
|
||||
export function isValidHexColor(color: string): boolean {
|
||||
return /^#[0-9A-F]{6}$/i.test(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a default color for servers that don't have one set
|
||||
*/
|
||||
export function getDefaultServerColor(): string {
|
||||
return '#3b82f6'; // Blue-500 from Tailwind
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { scriptsRouter } from "~/server/api/routers/scripts";
|
||||
import { 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
|
||||
|
||||
@@ -203,5 +203,342 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
stats: null
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Auto-detect LXC containers with community-script tag
|
||||
autoDetectLXCContainers: publicProcedure
|
||||
.input(z.object({ serverId: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
console.log('=== AUTO-DETECT API ENDPOINT CALLED ===');
|
||||
console.log('Input received:', input);
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
|
||||
try {
|
||||
console.log('Starting auto-detect LXC containers for server ID:', input.serverId);
|
||||
|
||||
const db = getDatabase();
|
||||
const server = db.getServerById(input.serverId);
|
||||
|
||||
if (!server) {
|
||||
console.error('Server not found for ID:', input.serverId);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server not found',
|
||||
detectedContainers: []
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Found server:', (server as any).name, 'at', (server as any).ip);
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Test SSH connection first
|
||||
console.log('Testing SSH connection...');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
console.log('SSH connection test result:', connectionTest);
|
||||
|
||||
if (!(connectionTest as any).success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
|
||||
detectedContainers: []
|
||||
};
|
||||
}
|
||||
|
||||
console.log('SSH connection successful, scanning for LXC containers...');
|
||||
|
||||
// Use the working approach - manual loop through all config files
|
||||
const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`;
|
||||
let detectedContainers: any[] = [];
|
||||
|
||||
console.log('Executing manual loop command...');
|
||||
console.log('Command:', command);
|
||||
|
||||
let commandOutput = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
command,
|
||||
(data: string) => {
|
||||
commandOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error('Command error:', error);
|
||||
},
|
||||
(exitCode: number) => {
|
||||
console.log('Command exit code:', exitCode);
|
||||
|
||||
// Parse the complete output to get config file paths that contain community-script tag
|
||||
const configFiles = commandOutput.split('\n')
|
||||
.filter((line: string) => line.trim())
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line.endsWith('.conf'));
|
||||
|
||||
console.log('Found config files with community-script tag:', configFiles.length);
|
||||
console.log('Config files:', configFiles);
|
||||
|
||||
// Process each config file to extract hostname
|
||||
const processPromises = configFiles.map(async (configPath: string) => {
|
||||
try {
|
||||
const containerId = configPath.split('/').pop()?.replace('.conf', '');
|
||||
if (!containerId) return null;
|
||||
|
||||
console.log('Processing container:', containerId, 'from', configPath);
|
||||
|
||||
// Read the config file content
|
||||
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return new Promise<any>((readResolve) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
readCommand,
|
||||
(configData: string) => {
|
||||
// Parse config file for hostname
|
||||
const lines = configData.split('\n');
|
||||
let hostname = '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith('hostname:')) {
|
||||
hostname = trimmedLine.substring(9).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostname) {
|
||||
const container = {
|
||||
containerId,
|
||||
hostname,
|
||||
configPath,
|
||||
serverId: (server as any).id,
|
||||
serverName: (server as any).name
|
||||
};
|
||||
console.log('Adding container to detected list:', container);
|
||||
readResolve(container);
|
||||
} else {
|
||||
console.log('No hostname found for', containerId);
|
||||
readResolve(null);
|
||||
}
|
||||
},
|
||||
(readError: string) => {
|
||||
console.error(`Error reading config file ${configPath}:`, readError);
|
||||
readResolve(null);
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
readResolve(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing config file ${configPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all config files to be processed
|
||||
void Promise.all(processPromises).then((results) => {
|
||||
detectedContainers = results.filter(result => result !== null);
|
||||
console.log('Final detected containers:', detectedContainers.length);
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
console.error('Error processing config files:', error);
|
||||
reject(new Error(`Error processing config files: ${error}`));
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
console.log('Detected containers:', detectedContainers.length);
|
||||
|
||||
// Get existing scripts to check for duplicates
|
||||
const existingScripts = db.getAllInstalledScripts();
|
||||
|
||||
// Create installed script records for detected containers (skip duplicates)
|
||||
const createdScripts = [];
|
||||
const skippedScripts = [];
|
||||
|
||||
for (const container of detectedContainers) {
|
||||
try {
|
||||
// Check if a script with this container_id and server_id already exists
|
||||
const duplicate = existingScripts.find((script: any) =>
|
||||
script.container_id === container.containerId &&
|
||||
script.server_id === container.serverId
|
||||
);
|
||||
|
||||
if (duplicate) {
|
||||
console.log(`Skipping duplicate: ${container.hostname} (${container.containerId}) already exists`);
|
||||
skippedScripts.push({
|
||||
containerId: container.containerId,
|
||||
hostname: container.hostname,
|
||||
serverName: container.serverName
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('Creating script record for:', container.hostname, container.containerId);
|
||||
const result = db.createInstalledScript({
|
||||
script_name: container.hostname,
|
||||
script_path: `detected/${container.hostname}`,
|
||||
container_id: container.containerId,
|
||||
server_id: container.serverId,
|
||||
execution_mode: 'ssh',
|
||||
status: 'success',
|
||||
output_log: `Auto-detected from LXC config: ${container.configPath}`
|
||||
});
|
||||
|
||||
createdScripts.push({
|
||||
id: result.lastInsertRowid,
|
||||
containerId: container.containerId,
|
||||
hostname: container.hostname,
|
||||
serverName: container.serverName
|
||||
});
|
||||
console.log('Created script record with ID:', result.lastInsertRowid);
|
||||
} catch (error) {
|
||||
console.error(`Error creating script record for ${container.hostname}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const message = skippedScripts.length > 0
|
||||
? `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
|
||||
: `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts.`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: message,
|
||||
detectedContainers: createdScripts,
|
||||
skippedContainers: skippedScripts
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in autoDetectLXCContainers:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to auto-detect LXC containers',
|
||||
detectedContainers: []
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Cleanup orphaned scripts (check if LXC containers still exist on servers)
|
||||
cleanupOrphanedScripts: publicProcedure
|
||||
.mutation(async () => {
|
||||
try {
|
||||
console.log('=== CLEANUP ORPHANED SCRIPTS API ENDPOINT CALLED ===');
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
|
||||
const db = getDatabase();
|
||||
const allScripts = db.getAllInstalledScripts();
|
||||
const allServers = db.getAllServers();
|
||||
|
||||
console.log('Found scripts:', allScripts.length);
|
||||
console.log('Found servers:', allServers.length);
|
||||
|
||||
if (allScripts.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'No scripts to check',
|
||||
deletedCount: 0,
|
||||
deletedScripts: []
|
||||
};
|
||||
}
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
const deletedScripts: string[] = [];
|
||||
const scriptsToCheck = allScripts.filter((script: any) =>
|
||||
script.execution_mode === 'ssh' &&
|
||||
script.server_id &&
|
||||
script.container_id
|
||||
);
|
||||
|
||||
console.log('Scripts to check for cleanup:', scriptsToCheck.length);
|
||||
|
||||
for (const script of scriptsToCheck) {
|
||||
try {
|
||||
const scriptData = script as any;
|
||||
const server = allServers.find((s: any) => s.id === scriptData.server_id);
|
||||
if (!server) {
|
||||
console.log(`Server not found for script ${scriptData.script_name}, marking for deletion`);
|
||||
db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Checking script ${scriptData.script_name} on server ${(server as any).name}`);
|
||||
|
||||
// Test SSH connection
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
if (!(connectionTest as any).success) {
|
||||
console.log(`SSH connection failed for server ${(server as any).name}, skipping script ${scriptData.script_name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the container config file still exists
|
||||
const checkCommand = `test -f "/etc/pve/lxc/${scriptData.container_id}.conf" && echo "exists" || echo "not_found"`;
|
||||
|
||||
const containerExists = await new Promise<boolean>((resolve) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
checkCommand,
|
||||
(data: string) => {
|
||||
resolve(data.trim() === 'exists');
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`Error checking container ${scriptData.script_name}:`, error);
|
||||
resolve(false);
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!containerExists) {
|
||||
console.log(`Container ${scriptData.container_id} not found on server ${(server as any).name}, deleting script ${scriptData.script_name}`);
|
||||
db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
} else {
|
||||
console.log(`Container ${scriptData.container_id} still exists on server ${(server as any).name}, keeping script ${scriptData.script_name}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error checking script ${(script as any).script_name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Cleanup completed. Deleted scripts:', deletedScripts);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Cleanup completed. ${deletedScripts.length} orphaned script(s) removed.`,
|
||||
deletedCount: deletedScripts.length,
|
||||
deletedScripts: deletedScripts
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in cleanupOrphanedScripts:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to cleanup orphaned scripts',
|
||||
deletedCount: 0,
|
||||
deletedScripts: []
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -163,12 +163,22 @@ export const scriptsRouter = createTRPCRouter({
|
||||
const script = scripts.find(s => s.slug === card.slug);
|
||||
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
|
||||
|
||||
// Extract OS and version from first install method
|
||||
const firstInstallMethod = script?.install_methods?.[0];
|
||||
const os = firstInstallMethod?.resources?.os;
|
||||
const version = firstInstallMethod?.resources?.version;
|
||||
|
||||
return {
|
||||
...card,
|
||||
categories: script?.categories ?? [],
|
||||
categoryNames: categoryNames,
|
||||
// Add date_created from script
|
||||
date_created: script?.date_created,
|
||||
// Add OS and version from install methods
|
||||
os: os,
|
||||
version: version,
|
||||
// Add interface port
|
||||
interface_port: script?.interface_port,
|
||||
} as ScriptCard;
|
||||
});
|
||||
|
||||
|
||||
225
src/server/api/routers/version.ts
Normal file
225
src/server/api/routers/version.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { spawn } from "child_process";
|
||||
import { env } from "~/env";
|
||||
import { existsSync, createWriteStream } from "fs";
|
||||
import stripAnsi from "strip-ansi";
|
||||
|
||||
interface GitHubRelease {
|
||||
tag_name: string;
|
||||
name: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
// Helper function to fetch from GitHub API with optional authentication
|
||||
async function fetchGitHubAPI(url: string) {
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'ProxmoxVE-Local'
|
||||
};
|
||||
|
||||
// Add authentication header if token is available
|
||||
if (env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
return fetch(url, { headers });
|
||||
}
|
||||
|
||||
export const versionRouter = createTRPCRouter({
|
||||
// Get current local version
|
||||
getCurrentVersion: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const versionPath = join(process.cwd(), 'VERSION');
|
||||
const version = await readFile(versionPath, 'utf-8');
|
||||
return {
|
||||
success: true,
|
||||
version: version.trim()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading VERSION file:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to read VERSION file',
|
||||
version: null
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
getLatestRelease: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const release: GitHubRelease = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
release: {
|
||||
tagName: release.tag_name,
|
||||
name: release.name,
|
||||
publishedAt: release.published_at,
|
||||
htmlUrl: release.html_url
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching latest release:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch latest release',
|
||||
release: null
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
getVersionStatus: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
|
||||
const versionPath = join(process.cwd(), 'VERSION');
|
||||
const currentVersion = (await readFile(versionPath, 'utf-8')).trim();
|
||||
|
||||
|
||||
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const release: GitHubRelease = await response.json();
|
||||
const latestVersion = release.tag_name.replace('v', '');
|
||||
|
||||
|
||||
const isUpToDate = currentVersion === latestVersion;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isUpToDate,
|
||||
updateAvailable: !isUpToDate,
|
||||
releaseInfo: {
|
||||
tagName: release.tag_name,
|
||||
name: release.name,
|
||||
publishedAt: release.published_at,
|
||||
htmlUrl: release.html_url
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking version status:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check version status',
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
updateAvailable: false,
|
||||
releaseInfo: null
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Get update logs from the log file
|
||||
getUpdateLogs: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const logPath = join(process.cwd(), 'update.log');
|
||||
|
||||
if (!existsSync(logPath)) {
|
||||
return {
|
||||
success: true,
|
||||
logs: [],
|
||||
isComplete: false
|
||||
};
|
||||
}
|
||||
|
||||
const logs = await readFile(logPath, 'utf-8');
|
||||
const logLines = logs.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => stripAnsi(line)); // Strip ANSI color codes
|
||||
|
||||
// Check if update is complete by looking for completion indicators
|
||||
const isComplete = logLines.some(line =>
|
||||
line.includes('Update complete') ||
|
||||
line.includes('Server restarting') ||
|
||||
line.includes('npm start') ||
|
||||
line.includes('Restarting server') ||
|
||||
line.includes('Server started') ||
|
||||
line.includes('Ready on http') ||
|
||||
line.includes('Application started') ||
|
||||
line.includes('Service enabled and started successfully') ||
|
||||
line.includes('Service is running') ||
|
||||
line.includes('Update completed successfully')
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
logs: logLines,
|
||||
isComplete
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading update logs:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to read update logs',
|
||||
logs: [],
|
||||
isComplete: false
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Execute update script
|
||||
executeUpdate: publicProcedure
|
||||
.mutation(async () => {
|
||||
try {
|
||||
const updateScriptPath = join(process.cwd(), 'update.sh');
|
||||
const logPath = join(process.cwd(), 'update.log');
|
||||
|
||||
// Clear/create the log file
|
||||
await writeFile(logPath, '', 'utf-8');
|
||||
|
||||
// Spawn the update script as a detached process using nohup
|
||||
// This allows it to run independently and kill the parent Node.js process
|
||||
// Redirect output to log file
|
||||
const child = spawn('bash', [updateScriptPath], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
detached: true
|
||||
});
|
||||
|
||||
// Capture stdout and stderr to log file
|
||||
const logStream = createWriteStream(logPath, { flags: 'a' });
|
||||
child.stdout?.pipe(logStream);
|
||||
child.stderr?.pipe(logStream);
|
||||
|
||||
// Unref the child process so it doesn't keep the parent alive
|
||||
child.unref();
|
||||
|
||||
// Immediately return success since we can't wait for completion
|
||||
// The script will handle its own logging and restart
|
||||
return {
|
||||
success: true,
|
||||
message: 'Update started in background. The server will restart automatically when complete.',
|
||||
output: '',
|
||||
error: ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error executing update script:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to execute update script: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
output: '',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -25,8 +25,11 @@ export class ScriptExecutionHandler {
|
||||
|
||||
private handleConnection(ws: WebSocket, _request: IncomingMessage) {
|
||||
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
|
||||
|
||||
const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string };
|
||||
void this.handleMessage(ws, message);
|
||||
} catch (error) {
|
||||
@@ -40,20 +43,20 @@ export class ScriptExecutionHandler {
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
|
||||
// Clean up any active executions for this connection
|
||||
this.cleanupActiveExecutions(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
ws.on('error', (_error) => {
|
||||
this.cleanupActiveExecutions(ws);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any }) {
|
||||
const { action, scriptPath, executionId, mode, server } = message;
|
||||
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any; input?: string }) {
|
||||
const { action, scriptPath, executionId, mode, server, input } = message;
|
||||
|
||||
|
||||
console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
@@ -74,6 +77,20 @@ export class ScriptExecutionHandler {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
if (executionId && input !== undefined) {
|
||||
|
||||
this.sendInputToExecution(executionId, input);
|
||||
} else {
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Missing executionId or input data',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
@@ -84,7 +101,6 @@ export class ScriptExecutionHandler {
|
||||
}
|
||||
|
||||
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) {
|
||||
console.log('startScriptExecution called with:', { scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
|
||||
|
||||
try {
|
||||
// Check if execution is already running
|
||||
@@ -100,10 +116,7 @@ export class ScriptExecutionHandler {
|
||||
let process: any;
|
||||
|
||||
if (mode === 'ssh' && server) {
|
||||
// SSH execution
|
||||
console.log('Starting SSH execution:', { scriptPath, server });
|
||||
console.log('SSH execution mode detected, calling SSH service...');
|
||||
console.log('Mode check: mode=', mode, 'server=', !!server);
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'start',
|
||||
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
|
||||
@@ -111,13 +124,11 @@ export class ScriptExecutionHandler {
|
||||
});
|
||||
|
||||
const sshService = getSSHExecutionService();
|
||||
console.log('SSH service obtained, calling executeScript...');
|
||||
console.log('SSH service object:', typeof sshService, sshService.constructor.name);
|
||||
|
||||
try {
|
||||
const result = await sshService.executeScript(server as Server, scriptPath,
|
||||
(data: string) => {
|
||||
console.log('SSH onData callback:', data.substring(0, 100) + '...');
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: data,
|
||||
@@ -125,7 +136,7 @@ export class ScriptExecutionHandler {
|
||||
});
|
||||
},
|
||||
(error: string) => {
|
||||
console.log('SSH onError callback:', error);
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: error,
|
||||
@@ -133,7 +144,7 @@ export class ScriptExecutionHandler {
|
||||
});
|
||||
},
|
||||
(code: number) => {
|
||||
console.log('SSH onExit callback, code:', code);
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'end',
|
||||
data: `SSH script execution finished with code: ${code}`,
|
||||
@@ -142,10 +153,10 @@ export class ScriptExecutionHandler {
|
||||
this.activeExecutions.delete(executionId);
|
||||
}
|
||||
);
|
||||
console.log('SSH service executeScript completed, result:', result);
|
||||
|
||||
process = (result as any).process;
|
||||
} catch (sshError) {
|
||||
console.error('SSH service executeScript failed:', sshError);
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
|
||||
@@ -154,10 +165,7 @@ export class ScriptExecutionHandler {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Local execution
|
||||
console.log('Starting local execution:', { scriptPath });
|
||||
console.log('Local execution mode detected, calling local script manager...');
|
||||
console.log('Mode check: mode=', mode, 'server=', !!server, 'condition result:', mode === 'ssh' && server);
|
||||
|
||||
|
||||
// Validate script path
|
||||
const validation = scriptManager.validateScriptPath(scriptPath);
|
||||
@@ -249,6 +257,59 @@ export class ScriptExecutionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private sendInputToExecution(executionId: string, input: string) {
|
||||
|
||||
const execution = this.activeExecutions.get(executionId);
|
||||
|
||||
|
||||
if (execution?.process) {
|
||||
|
||||
try {
|
||||
// Check if it's a pty process (SSH) or regular process
|
||||
if (typeof execution.process.write === 'function' && !execution.process.stdin) {
|
||||
|
||||
|
||||
execution.process.write(input);
|
||||
|
||||
|
||||
// Send confirmation back to client
|
||||
this.sendMessage(execution.ws, {
|
||||
type: 'output',
|
||||
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} else if (execution.process.stdin && !execution.process.stdin.destroyed) {
|
||||
|
||||
execution.process.stdin.write(input);
|
||||
|
||||
|
||||
this.sendMessage(execution.ws, {
|
||||
type: 'output',
|
||||
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} else {
|
||||
|
||||
this.sendMessage(execution.ws, {
|
||||
type: 'error',
|
||||
data: 'Process input not available',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
this.sendMessage(execution.ws, {
|
||||
type: 'error',
|
||||
data: `Failed to send input: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No active execution found - this case is already handled above
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
|
||||
@@ -16,12 +16,68 @@ class DatabaseService {
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
ip TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
password TEXT,
|
||||
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both')),
|
||||
ssh_key TEXT,
|
||||
ssh_key_passphrase TEXT,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
color TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: Add new columns to existing servers table
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both'))
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN ssh_key TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN ssh_key_passphrase TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN ssh_port INTEGER DEFAULT 22
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN color TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Update existing servers to have auth_type='password' if not set
|
||||
this.db.exec(`
|
||||
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
|
||||
`);
|
||||
|
||||
// Update existing servers to have ssh_port=22 if not set
|
||||
this.db.exec(`
|
||||
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
|
||||
`);
|
||||
|
||||
// Create installed_scripts table if it doesn't exist
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS installed_scripts (
|
||||
@@ -53,12 +109,12 @@ class DatabaseService {
|
||||
* @param {import('../types/server').CreateServerData} serverData
|
||||
*/
|
||||
createServer(serverData) {
|
||||
const { name, ip, user, password } = serverData;
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO servers (name, ip, user, password)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
return stmt.run(name, ip, user, password);
|
||||
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color);
|
||||
}
|
||||
|
||||
getAllServers() {
|
||||
@@ -79,13 +135,13 @@ class DatabaseService {
|
||||
* @param {import('../types/server').CreateServerData} serverData
|
||||
*/
|
||||
updateServer(id, serverData) {
|
||||
const { name, ip, user, password } = serverData;
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE servers
|
||||
SET name = ?, ip = ?, user = ?, password = ?
|
||||
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, color = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
return stmt.run(name, ip, user, password, id);
|
||||
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color, id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +179,8 @@ class DatabaseService {
|
||||
s.name as server_name,
|
||||
s.ip as server_ip,
|
||||
s.user as server_user,
|
||||
s.password as server_password
|
||||
s.password as server_password,
|
||||
s.color as server_color
|
||||
FROM installed_scripts inst
|
||||
LEFT JOIN servers s ON inst.server_id = s.id
|
||||
ORDER BY inst.installation_date DESC
|
||||
|
||||
@@ -1,16 +1,131 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { spawn as ptySpawn } from 'node-pty';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {Object} Server
|
||||
* @property {string} ip - Server IP address
|
||||
* @property {string} user - Username
|
||||
* @property {string} password - Password
|
||||
* @property {string} [password] - Password (optional)
|
||||
* @property {string} name - Server name
|
||||
* @property {string} [auth_type] - Authentication type ('password', 'key', 'both')
|
||||
* @property {string} [ssh_key] - SSH private key content
|
||||
* @property {string} [ssh_key_passphrase] - SSH key passphrase
|
||||
* @property {number} [ssh_port] - SSH port (default: 22)
|
||||
*/
|
||||
|
||||
class SSHExecutionService {
|
||||
/**
|
||||
* Create a temporary SSH key file for authentication
|
||||
* @param {Server} server - Server configuration
|
||||
* @returns {string} Path to temporary key file
|
||||
*/
|
||||
createTempKeyFile(server) {
|
||||
const { ssh_key } = server;
|
||||
if (!ssh_key) {
|
||||
throw new Error('SSH key not provided');
|
||||
}
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
|
||||
const tempKeyPath = join(tempDir, 'private_key');
|
||||
|
||||
writeFileSync(tempKeyPath, ssh_key);
|
||||
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
||||
|
||||
return tempKeyPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SSH command arguments based on authentication type
|
||||
* @param {Server} server - Server configuration
|
||||
* @param {string|null} [tempKeyPath=null] - Path to temporary key file (if using key auth)
|
||||
* @returns {{command: string, args: string[]}} Command and arguments for SSH
|
||||
*/
|
||||
buildSSHCommand(server, tempKeyPath = null) {
|
||||
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_port = 22 } = server;
|
||||
|
||||
const baseArgs = [
|
||||
'-t',
|
||||
'-p', ssh_port.toString(),
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'RequestTTY=yes',
|
||||
'-o', 'SetEnv=TERM=xterm-256color',
|
||||
'-o', 'SetEnv=COLUMNS=120',
|
||||
'-o', 'SetEnv=LINES=30',
|
||||
'-o', 'SetEnv=COLORTERM=truecolor',
|
||||
'-o', 'SetEnv=FORCE_COLOR=1',
|
||||
'-o', 'SetEnv=NO_COLOR=0',
|
||||
'-o', 'SetEnv=CLICOLOR=1',
|
||||
'-o', 'SetEnv=CLICOLOR_FORCE=1'
|
||||
];
|
||||
|
||||
if (auth_type === 'key') {
|
||||
// SSH key authentication
|
||||
if (tempKeyPath) {
|
||||
baseArgs.push('-i', tempKeyPath);
|
||||
baseArgs.push('-o', 'PasswordAuthentication=no');
|
||||
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
||||
}
|
||||
|
||||
if (ssh_key_passphrase) {
|
||||
return {
|
||||
command: 'sshpass',
|
||||
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
command: 'ssh',
|
||||
args: [...baseArgs, `${user}@${ip}`]
|
||||
};
|
||||
}
|
||||
} else if (auth_type === 'both') {
|
||||
// Try SSH key first, then password
|
||||
if (tempKeyPath) {
|
||||
baseArgs.push('-i', tempKeyPath);
|
||||
baseArgs.push('-o', 'PasswordAuthentication=yes');
|
||||
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
||||
|
||||
if (ssh_key_passphrase) {
|
||||
return {
|
||||
command: 'sshpass',
|
||||
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
command: 'ssh',
|
||||
args: [...baseArgs, `${user}@${ip}`]
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Fallback to password
|
||||
if (password) {
|
||||
return {
|
||||
command: 'sshpass',
|
||||
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
|
||||
};
|
||||
} else {
|
||||
throw new Error('Password is required for password authentication');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Password authentication (default)
|
||||
if (password) {
|
||||
return {
|
||||
command: 'sshpass',
|
||||
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
|
||||
};
|
||||
} else {
|
||||
throw new Error('Password is required for password authentication');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a script on a remote server via SSH
|
||||
* @param {Server} server - Server configuration
|
||||
@@ -21,7 +136,8 @@ class SSHExecutionService {
|
||||
* @returns {Promise<Object>} Process information
|
||||
*/
|
||||
async executeScript(server, scriptPath, onData, onError, onExit) {
|
||||
const { ip, user, password } = server;
|
||||
/** @type {string|null} */
|
||||
let tempKeyPath = null;
|
||||
|
||||
try {
|
||||
await this.transferScriptsFolder(server, onData, onError);
|
||||
@@ -29,29 +145,20 @@ class SSHExecutionService {
|
||||
return new Promise((resolve, reject) => {
|
||||
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
||||
|
||||
try {
|
||||
// Create temporary key file if using key authentication
|
||||
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
||||
tempKeyPath = this.createTempKeyFile(server);
|
||||
}
|
||||
|
||||
// Build SSH command based on authentication type
|
||||
const { command, args } = this.buildSSHCommand(server, tempKeyPath);
|
||||
|
||||
// Add the script execution command to the args
|
||||
args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`);
|
||||
|
||||
// Use ptySpawn for proper terminal emulation and color support
|
||||
const sshCommand = ptySpawn('sshpass', [
|
||||
'-p', password,
|
||||
'ssh',
|
||||
'-t',
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'PasswordAuthentication=yes',
|
||||
'-o', 'PubkeyAuthentication=no',
|
||||
'-o', 'RequestTTY=yes',
|
||||
'-o', 'SetEnv=TERM=xterm-256color',
|
||||
'-o', 'SetEnv=COLUMNS=120',
|
||||
'-o', 'SetEnv=LINES=30',
|
||||
'-o', 'SetEnv=COLORTERM=truecolor',
|
||||
'-o', 'SetEnv=FORCE_COLOR=1',
|
||||
'-o', 'SetEnv=NO_COLOR=0',
|
||||
'-o', 'SetEnv=CLICOLOR=1',
|
||||
'-o', 'SetEnv=CLICOLOR_FORCE=1',
|
||||
`${user}@${ip}`,
|
||||
`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`
|
||||
], {
|
||||
const sshCommand = ptySpawn(command, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
@@ -82,8 +189,34 @@ class SSHExecutionService {
|
||||
|
||||
resolve({
|
||||
process: sshCommand,
|
||||
kill: () => sshCommand.kill('SIGTERM')
|
||||
kill: () => {
|
||||
sshCommand.kill('SIGTERM');
|
||||
// Clean up temporary key file
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Clean up temporary key file on error
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -100,15 +233,44 @@ class SSHExecutionService {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async transferScriptsFolder(server, onData, onError) {
|
||||
const { ip, user, password } = server;
|
||||
const { ip, user, password, auth_type = 'password', ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||
/** @type {string|null} */
|
||||
let tempKeyPath = null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Create temporary key file if using key authentication
|
||||
if (auth_type === 'key' || auth_type === 'both') {
|
||||
if (ssh_key) {
|
||||
tempKeyPath = this.createTempKeyFile(server);
|
||||
}
|
||||
}
|
||||
|
||||
// Build rsync command based on authentication type
|
||||
let rshCommand;
|
||||
if (auth_type === 'key' && tempKeyPath) {
|
||||
if (ssh_key_passphrase) {
|
||||
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
} else {
|
||||
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
}
|
||||
} else if (auth_type === 'both' && tempKeyPath) {
|
||||
if (ssh_key_passphrase) {
|
||||
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
} else {
|
||||
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
}
|
||||
} else {
|
||||
// Fallback to password authentication
|
||||
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||
}
|
||||
|
||||
const rsyncCommand = spawn('rsync', [
|
||||
'-avz',
|
||||
'--delete',
|
||||
'--exclude=*.log',
|
||||
'--exclude=*.tmp',
|
||||
'--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null',
|
||||
`--rsh=${rshCommand}`,
|
||||
'scripts/',
|
||||
`${user}@${ip}:/tmp/scripts/`
|
||||
], {
|
||||
@@ -128,6 +290,17 @@ class SSHExecutionService {
|
||||
});
|
||||
|
||||
rsyncCommand.on('close', (code) => {
|
||||
// Clean up temporary key file
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
unlinkSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
@@ -136,8 +309,32 @@ class SSHExecutionService {
|
||||
});
|
||||
|
||||
rsyncCommand.on('error', (error) => {
|
||||
// Clean up temporary key file on error
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
unlinkSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Clean up temporary key file on error
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
unlinkSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,31 +348,24 @@ class SSHExecutionService {
|
||||
* @returns {Promise<Object>} Process information
|
||||
*/
|
||||
async executeCommand(server, command, onData, onError, onExit) {
|
||||
const { ip, user, password } = server;
|
||||
/** @type {string|null} */
|
||||
let tempKeyPath = null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Create temporary key file if using key authentication
|
||||
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
||||
tempKeyPath = this.createTempKeyFile(server);
|
||||
}
|
||||
|
||||
// Build SSH command based on authentication type
|
||||
const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath);
|
||||
|
||||
// Add the command to execute to the args
|
||||
args.push(command);
|
||||
|
||||
// Use ptySpawn for proper terminal emulation and color support
|
||||
const sshCommand = ptySpawn('sshpass', [
|
||||
'-p', password,
|
||||
'ssh',
|
||||
'-t',
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'PasswordAuthentication=yes',
|
||||
'-o', 'PubkeyAuthentication=no',
|
||||
'-o', 'RequestTTY=yes',
|
||||
'-o', 'SetEnv=TERM=xterm-256color',
|
||||
'-o', 'SetEnv=COLUMNS=120',
|
||||
'-o', 'SetEnv=LINES=30',
|
||||
'-o', 'SetEnv=COLORTERM=truecolor',
|
||||
'-o', 'SetEnv=FORCE_COLOR=1',
|
||||
'-o', 'SetEnv=NO_COLOR=0',
|
||||
'-o', 'SetEnv=CLICOLOR=1',
|
||||
`${user}@${ip}`,
|
||||
command
|
||||
], {
|
||||
const sshCommand = ptySpawn(sshCommandName, args, {
|
||||
name: 'xterm-color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
@@ -188,10 +378,49 @@ class SSHExecutionService {
|
||||
});
|
||||
|
||||
sshCommand.onExit((e) => {
|
||||
// Clean up temporary key file
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
unlinkSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
onExit(e.exitCode);
|
||||
});
|
||||
|
||||
resolve({ process: sshCommand });
|
||||
resolve({
|
||||
process: sshCommand,
|
||||
kill: () => {
|
||||
sshCommand.kill('SIGTERM');
|
||||
// Clean up temporary key file
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Clean up temporary key file on error
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
unlinkSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { writeFileSync, unlinkSync, chmodSync } from 'fs';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
class SSHService {
|
||||
/**
|
||||
@@ -10,39 +11,43 @@ class SSHService {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testConnection(server) {
|
||||
const { ip, user, password } = server;
|
||||
const { auth_type = 'password' } = server;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = 15000; // 15 seconds timeout for login test
|
||||
let resolved = false;
|
||||
|
||||
// Try sshpass first if available
|
||||
this.testWithSshpass(server).then(result => {
|
||||
// Choose authentication method based on auth_type
|
||||
let authPromise;
|
||||
if (auth_type === 'key') {
|
||||
authPromise = this.testWithSSHKey(server);
|
||||
} else if (auth_type === 'both') {
|
||||
// Try SSH key first, then password
|
||||
authPromise = this.testWithSSHKey(server).catch(() => this.testWithSshpass(server));
|
||||
} else {
|
||||
// Default to password authentication
|
||||
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
|
||||
}
|
||||
|
||||
authPromise.then(result => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
}).catch(() => {
|
||||
// If sshpass fails, try expect
|
||||
this.testWithExpect(server).then(result => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
}).catch(() => {
|
||||
// If both fail, return error
|
||||
// If primary method fails, return error
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve({
|
||||
success: false,
|
||||
message: 'SSH login test requires sshpass or expect - neither available or working',
|
||||
message: `SSH login test failed for ${auth_type} authentication`,
|
||||
details: {
|
||||
method: 'no_auth_tools'
|
||||
method: 'auth_failed',
|
||||
auth_type: auth_type
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set up overall timeout
|
||||
setTimeout(() => {
|
||||
@@ -64,7 +69,11 @@ class SSHService {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testWithSshpass(server) {
|
||||
const { ip, user, password } = server;
|
||||
const { ip, user, password, ssh_port = 22 } = server;
|
||||
|
||||
if (!password) {
|
||||
throw new Error('Password is required for password authentication');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = 10000;
|
||||
@@ -73,6 +82,7 @@ class SSHService {
|
||||
const sshCommand = spawn('sshpass', [
|
||||
'-p', password,
|
||||
'ssh',
|
||||
'-p', ssh_port.toString(),
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
@@ -156,7 +166,7 @@ class SSHService {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testWithExpect(server) {
|
||||
const { ip, user, password } = server;
|
||||
const { ip, user, password, ssh_port = 22 } = server;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = 10000;
|
||||
@@ -164,7 +174,7 @@ class SSHService {
|
||||
|
||||
const expectScript = `#!/usr/bin/expect -f
|
||||
set timeout 10
|
||||
spawn ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
|
||||
spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
|
||||
expect {
|
||||
"password:" {
|
||||
send "${password}\r"
|
||||
@@ -428,13 +438,14 @@ expect {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testSSHConnection(server) {
|
||||
const { ip, user } = server;
|
||||
const { ip, user, ssh_port = 22 } = server;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = 5000;
|
||||
let resolved = false;
|
||||
|
||||
const sshCommand = spawn('ssh', [
|
||||
'-p', ssh_port.toString(),
|
||||
'-o', 'ConnectTimeout=5',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
@@ -523,6 +534,148 @@ expect {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SSH connection using SSH key authentication
|
||||
* @param {import('../types/server').Server} server - Server configuration
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testWithSSHKey(server) {
|
||||
const { ip, user, ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||
|
||||
if (!ssh_key) {
|
||||
throw new Error('SSH key not provided');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = 10000;
|
||||
let resolved = false;
|
||||
let tempKeyPath = null;
|
||||
|
||||
try {
|
||||
// Create temporary key file
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
|
||||
tempKeyPath = join(tempDir, 'private_key');
|
||||
|
||||
// Write the private key to temporary file
|
||||
writeFileSync(tempKeyPath, ssh_key);
|
||||
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
||||
|
||||
// Build SSH command
|
||||
const sshArgs = [
|
||||
'-i', tempKeyPath,
|
||||
'-p', ssh_port.toString(),
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'PasswordAuthentication=no',
|
||||
'-o', 'PubkeyAuthentication=yes',
|
||||
`${user}@${ip}`,
|
||||
'echo "SSH_LOGIN_SUCCESS"'
|
||||
];
|
||||
|
||||
// Use sshpass if passphrase is provided
|
||||
let command, args;
|
||||
if (ssh_key_passphrase) {
|
||||
command = 'sshpass';
|
||||
args = ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...sshArgs];
|
||||
} else {
|
||||
command = 'ssh';
|
||||
args = sshArgs;
|
||||
}
|
||||
|
||||
const sshCommand = spawn(command, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
sshCommand.kill('SIGTERM');
|
||||
reject(new Error('SSH key login timeout'));
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
sshCommand.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
sshCommand.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
sshCommand.on('close', (code) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
|
||||
if (code === 0 && output.includes('SSH_LOGIN_SUCCESS')) {
|
||||
resolve({
|
||||
success: true,
|
||||
message: 'SSH key authentication successful - credentials verified',
|
||||
details: {
|
||||
server: server.name || 'Unknown',
|
||||
ip: ip,
|
||||
user: user,
|
||||
method: 'ssh_key_verified'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let errorMessage = 'SSH key authentication failed';
|
||||
|
||||
if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) {
|
||||
errorMessage = 'SSH key authentication failed - check key and permissions';
|
||||
} else if (errorOutput.includes('Connection refused')) {
|
||||
errorMessage = 'Connection refused - server may be down or SSH not running';
|
||||
} else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) {
|
||||
errorMessage = 'Host not found - check IP address';
|
||||
} else if (errorOutput.includes('Connection timed out')) {
|
||||
errorMessage = 'Connection timeout - server may be unreachable';
|
||||
} else if (errorOutput.includes('Load key') || errorOutput.includes('invalid format')) {
|
||||
errorMessage = 'Invalid SSH key format';
|
||||
} else if (errorOutput.includes('Enter passphrase')) {
|
||||
errorMessage = 'SSH key passphrase required but not provided';
|
||||
} else {
|
||||
errorMessage = `SSH key authentication failed: ${errorOutput.trim()}`;
|
||||
}
|
||||
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sshCommand.on('error', (error) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(error);
|
||||
}
|
||||
} finally {
|
||||
// Clean up temporary key file
|
||||
if (tempKeyPath) {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
// Also remove the temp directory
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -1,15 +1,134 @@
|
||||
@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%;
|
||||
}
|
||||
|
||||
@variant dark (&:is(.dark, .dark *));
|
||||
::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%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Semantic color utility classes */
|
||||
.bg-background { background-color: hsl(var(--background)); }
|
||||
.text-foreground { color: hsl(var(--foreground)); }
|
||||
.bg-card { background-color: hsl(var(--card)) !important; }
|
||||
.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 {
|
||||
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -22,3 +141,94 @@
|
||||
color: inherit;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
/* Enhanced terminal styling */
|
||||
.xterm {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Set basic background - let ANSI colors work naturally */
|
||||
.xterm .xterm-viewport {
|
||||
background-color: #0d1117;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
background-color: #0d1117;
|
||||
}
|
||||
|
||||
/* Better selection colors */
|
||||
.xterm .xterm-selection {
|
||||
background-color: #264f78;
|
||||
}
|
||||
|
||||
/* Mobile-specific improvements */
|
||||
@media (max-width: 640px) {
|
||||
/* Improve touch targets */
|
||||
button, .cursor-pointer {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Better text sizing on mobile */
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
/* Improve form elements on mobile */
|
||||
input, select, textarea {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
/* Better spacing for mobile */
|
||||
.space-y-2 > * + * {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.space-y-4 > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Improve modal and overlay positioning */
|
||||
.fixed.inset-0 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Better scroll behavior */
|
||||
.overflow-x-auto {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet improvements */
|
||||
@media (min-width: 641px) and (max-width: 1024px) {
|
||||
/* Better spacing for tablets */
|
||||
.container {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure proper viewport handling */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Mobile terminal centering - simple approach */
|
||||
.mobile-terminal {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.mobile-terminal .xterm {
|
||||
margin: 0 auto !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ export interface ScriptCard {
|
||||
categories?: number[];
|
||||
categoryNames?: string[];
|
||||
date_created?: string;
|
||||
os?: string;
|
||||
version?: string;
|
||||
interface_port?: number | null;
|
||||
}
|
||||
|
||||
export interface GitHubFile {
|
||||
|
||||
@@ -3,7 +3,12 @@ export interface Server {
|
||||
name: string;
|
||||
ip: string;
|
||||
user: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
auth_type?: 'password' | 'key' | 'both';
|
||||
ssh_key?: string;
|
||||
ssh_key_passphrase?: string;
|
||||
ssh_port?: number;
|
||||
color?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -12,7 +17,12 @@ export interface CreateServerData {
|
||||
name: string;
|
||||
ip: string;
|
||||
user: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
auth_type?: 'password' | 'key' | 'both';
|
||||
ssh_key?: string;
|
||||
ssh_key_passphrase?: string;
|
||||
ssh_port?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateServerData extends CreateServerData {
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
"~/*": ["./src/*"],
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
787
update.sh
Executable file
787
update.sh
Executable file
@@ -0,0 +1,787 @@
|
||||
#!/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"
|
||||
|
||||
# GitHub Personal Access Token for higher rate limits (optional)
|
||||
# Set GITHUB_TOKEN environment variable or create .github_token file
|
||||
GITHUB_TOKEN=""
|
||||
|
||||
# Global variable to track if service was running before update
|
||||
SERVICE_WAS_RUNNING=false
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Load GitHub token
|
||||
load_github_token() {
|
||||
# Try environment variable first
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
log "Using GitHub token from environment variable"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Try .env file
|
||||
if [ -f ".env" ]; then
|
||||
local env_token
|
||||
env_token=$(grep "^GITHUB_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' | tr -d "'" | tr -d '\n\r')
|
||||
if [ -n "$env_token" ]; then
|
||||
GITHUB_TOKEN="$env_token"
|
||||
log "Using GitHub token from .env file"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try .github_token file
|
||||
if [ -f ".github_token" ]; then
|
||||
GITHUB_TOKEN=$(cat .github_token | tr -d '\n\r')
|
||||
log "Using GitHub token from .github_token file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Try ~/.github_token file
|
||||
if [ -f "$HOME/.github_token" ]; then
|
||||
GITHUB_TOKEN=$(cat "$HOME/.github_token" | tr -d '\n\r')
|
||||
log "Using GitHub token from ~/.github_token file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warning "No GitHub token found. Using unauthenticated requests (lower rate limits)"
|
||||
log_warning "To use a token, add GITHUB_TOKEN=your_token to .env file or set GITHUB_TOKEN environment variable"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Initialize log file
|
||||
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 curl_opts="-s --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 3"
|
||||
|
||||
# Add authentication header if token is available
|
||||
if [ -n "$GITHUB_TOKEN" ]; then
|
||||
curl_opts="$curl_opts -H \"Authorization: token $GITHUB_TOKEN\""
|
||||
log "Using authenticated GitHub API request"
|
||||
else
|
||||
log "Using unauthenticated GitHub API request (lower rate limits)"
|
||||
fi
|
||||
|
||||
local release_info
|
||||
if ! release_info=$(eval "curl $curl_opts \"$GITHUB_API/releases/latest\""); then
|
||||
log_error "Failed to fetch release information from GitHub API (timeout or network error)"
|
||||
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
|
||||
if ! curl -L --connect-timeout 30 --max-time 300 --retry 3 --retry-delay 5 -o "$archive_file" "$download_url" 2>/dev/null; then
|
||||
log_error "Failed to download release from GitHub"
|
||||
rm -rf "$temp_dir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
log_success "Downloaded release"
|
||||
|
||||
# Extract release
|
||||
if ! tar -xzf "$archive_file" -C "$temp_dir" 2>/dev/null; then
|
||||
log_error "Failed to extract release"
|
||||
rm -rf "$temp_dir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the extracted directory (GitHub tarballs have a root directory)
|
||||
local extracted_dir
|
||||
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" 2>/dev/null | head -1)
|
||||
|
||||
# Try alternative patterns if not found
|
||||
if [ -z "$extracted_dir" ]; then
|
||||
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -name "${REPO_NAME}-*" 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$extracted_dir" ]; then
|
||||
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d ! -name "$temp_dir" 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$extracted_dir" ]; then
|
||||
log_error "Could not find extracted directory"
|
||||
rm -rf "$temp_dir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Release extracted successfully"
|
||||
echo "$extracted_dir"
|
||||
}
|
||||
|
||||
# Clear the original directory before updating
|
||||
clear_original_directory() {
|
||||
log "Clearing original directory..."
|
||||
|
||||
# Remove old lock files and node_modules before update
|
||||
rm -f package-lock.json 2>/dev/null
|
||||
rm -rf node_modules 2>/dev/null
|
||||
|
||||
# List of files/directories to preserve (already backed up)
|
||||
local preserve_patterns=(
|
||||
"data"
|
||||
".env"
|
||||
"*.log"
|
||||
"update.log"
|
||||
"*.backup"
|
||||
"*.bak"
|
||||
".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() {
|
||||
# systemctl status returns 0-3 if service exists (running, exited, failed, etc.)
|
||||
# and returns 4 if service unit is not found
|
||||
systemctl status pvescriptslocal.service &>/dev/null
|
||||
local exit_code=$?
|
||||
if [ $exit_code -le 3 ]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# Stop the application before updating
|
||||
stop_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 is running and disable it temporarily
|
||||
if check_service && systemctl is-active --quiet pvescriptslocal.service; then
|
||||
log "Disabling systemd service temporarily to prevent auto-restart..."
|
||||
if systemctl disable pvescriptslocal.service; then
|
||||
log_success "Service disabled successfully"
|
||||
else
|
||||
log_error "Failed to disable service"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log "No running systemd service found"
|
||||
fi
|
||||
|
||||
# Kill any remaining npm/node processes
|
||||
log "Killing any remaining npm/node processes..."
|
||||
local pids
|
||||
pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then
|
||||
log "Found running processes: $pids"
|
||||
pkill -9 -f "node server.js" 2>/dev/null || true
|
||||
pkill -9 -f "npm start" 2>/dev/null || true
|
||||
sleep 2
|
||||
log_success "Processes killed"
|
||||
else
|
||||
log "No running processes found"
|
||||
fi
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# Verify critical files exist in source
|
||||
if [ ! -f "$actual_source_dir/package.json" ]; then
|
||||
log_error "package.json not found in source directory!"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Use process substitution instead of pipe to avoid subshell issues
|
||||
local files_copied=0
|
||||
local files_excluded=0
|
||||
|
||||
# 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"
|
||||
|
||||
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
|
||||
|
||||
if ! cp "$file" "$rel_path"; then
|
||||
log_error "Failed to copy $rel_path"
|
||||
rm -f "$file_list"
|
||||
return 1
|
||||
fi
|
||||
files_copied=$((files_copied + 1))
|
||||
else
|
||||
files_excluded=$((files_excluded + 1))
|
||||
fi
|
||||
done < "$file_list"
|
||||
|
||||
# Clean up temporary file
|
||||
rm -f "$file_list"
|
||||
|
||||
# Verify critical files were copied
|
||||
if [ ! -f "package.json" ]; then
|
||||
log_error "package.json was not copied to target directory!"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "package-lock.json" ]; then
|
||||
log_warning "package-lock.json was not copied!"
|
||||
fi
|
||||
|
||||
log_success "Application files updated successfully ($files_copied files)"
|
||||
}
|
||||
|
||||
# Install dependencies and build
|
||||
install_and_build() {
|
||||
log "Installing dependencies..."
|
||||
|
||||
# Verify package.json exists
|
||||
if [ ! -f "package.json" ]; then
|
||||
log_error "package.json not found! Cannot install dependencies."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "package-lock.json" ]; then
|
||||
log_warning "No package-lock.json found, npm will generate one"
|
||||
fi
|
||||
|
||||
# Create temporary file for npm output
|
||||
local npm_log="/tmp/npm_install_$$.log"
|
||||
|
||||
# Ensure NODE_ENV is not set to production during install (we need devDependencies for build)
|
||||
local old_node_env="${NODE_ENV:-}"
|
||||
export NODE_ENV=development
|
||||
|
||||
# Run npm install to get ALL dependencies including devDependencies
|
||||
if ! npm install --include=dev > "$npm_log" 2>&1; then
|
||||
log_error "Failed to install dependencies"
|
||||
log_error "npm install output (last 30 lines):"
|
||||
tail -30 "$npm_log" | while read -r line; do
|
||||
log_error "NPM: $line"
|
||||
done
|
||||
rm -f "$npm_log"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Restore NODE_ENV
|
||||
if [ -n "$old_node_env" ]; then
|
||||
export NODE_ENV="$old_node_env"
|
||||
else
|
||||
unset NODE_ENV
|
||||
fi
|
||||
|
||||
log_success "Dependencies installed successfully"
|
||||
rm -f "$npm_log"
|
||||
|
||||
log "Building application..."
|
||||
# Set NODE_ENV to production for build
|
||||
export NODE_ENV=production
|
||||
|
||||
# Create temporary file for npm build output
|
||||
local build_log="/tmp/npm_build_$$.log"
|
||||
|
||||
if ! npm run build > "$build_log" 2>&1; then
|
||||
log_error "Failed to build application"
|
||||
log_error "npm run build output:"
|
||||
cat "$build_log" | while read -r line; do
|
||||
log_error "BUILD: $line"
|
||||
done
|
||||
rm -f "$build_log"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Log success and clean up
|
||||
log_success "Application built successfully"
|
||||
rm -f "$build_log"
|
||||
|
||||
log_success "Dependencies installed and application built successfully"
|
||||
}
|
||||
|
||||
# Start the application after updating
|
||||
start_application() {
|
||||
log "Starting application..."
|
||||
|
||||
# Use the global variable to determine how to start
|
||||
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
|
||||
log "Service was running before update, re-enabling and starting systemd service..."
|
||||
if systemctl enable --now pvescriptslocal.service; then
|
||||
systemctl restart pvescriptslocal.service
|
||||
log_success "Service enabled and 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 enable/start service, falling back to npm start"
|
||||
start_with_npm
|
||||
fi
|
||||
else
|
||||
log "Service was not running before update or no service exists, 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() {
|
||||
# Check if this is the relocated/detached version first
|
||||
if [ "${1:-}" = "--relocated" ]; then
|
||||
export PVE_UPDATE_RELOCATED=1
|
||||
init_log
|
||||
log "Running as detached process"
|
||||
sleep 3
|
||||
|
||||
else
|
||||
init_log
|
||||
fi
|
||||
|
||||
# Check if we're running from the application directory and not already relocated
|
||||
if [ -z "${PVE_UPDATE_RELOCATED:-}" ] && [ -f "package.json" ] && [ -f "server.js" ]; then
|
||||
log "Detected running from application directory"
|
||||
bash "$0" --relocated
|
||||
exit $?
|
||||
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)"
|
||||
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
|
||||
}
|
||||
else
|
||||
log_error "Could not find application directory"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies
|
||||
|
||||
# Load GitHub token for higher rate limits
|
||||
load_github_token
|
||||
|
||||
# Check if service was running before update
|
||||
if check_service && systemctl is-active --quiet pvescriptslocal.service; then
|
||||
SERVICE_WAS_RUNNING=true
|
||||
else
|
||||
SERVICE_WAS_RUNNING=false
|
||||
fi
|
||||
|
||||
# Get latest release info
|
||||
local release_info
|
||||
release_info=$(get_latest_release)
|
||||
|
||||
# Backup data directory
|
||||
backup_data
|
||||
|
||||
# Stop the application before updating
|
||||
stop_application
|
||||
|
||||
# Download and extract release
|
||||
local source_dir
|
||||
source_dir=$(download_release "$release_info")
|
||||
|
||||
# Clear the original directory before updating
|
||||
clear_original_directory
|
||||
|
||||
# Update files
|
||||
if ! update_files "$source_dir"; then
|
||||
log_error "File update failed, rolling back..."
|
||||
rollback
|
||||
fi
|
||||
|
||||
# Restore .env and data directory before building
|
||||
restore_backup_files
|
||||
|
||||
# Install dependencies and build
|
||||
if ! install_and_build; then
|
||||
log_error "Install and build failed, rolling back..."
|
||||
rollback
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$source_dir"
|
||||
rm -rf "/tmp/pve-update-$$"
|
||||
|
||||
# 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
|
||||
Reference in New Issue
Block a user