Compare commits

..

198 Commits

Author SHA1 Message Date
Michel Rögl-Brunner
4628e67e5c Fix PBS certificate validation: pass PBS_FINGERPRINT, optional fingerprint for trusted CA
- Pass stored pbs_fingerprint as PBS_FINGERPRINT in login, snapshot list, and restore
- Allow empty fingerprint so trusted-CA PBS works without entering one
- Make fingerprint field optional in PBSCredentialsModal with updated helper text

Fixes #438
2026-01-29 13:55:53 +01:00
Michel Roegl-Brunner
578fa28461 Merge pull request #474 from community-scripts/fix/404
fix: allow domain names for APT Cacher in container creation UI
2026-01-29 13:42:31 +01:00
Michel Rögl-Brunner
9e6154b0de fix: allow domain names for APT Cacher in container creation UI
- Add validateHostname and validateAptCacherAddress (IPv4 or hostname)
- Use new validator for var_apt_cacher_ip; error message: Invalid IPv4 or hostname
- Label: APT Cacher host or IP; placeholder shows IP or hostname example

Fixes #404
2026-01-29 13:40:19 +01:00
Michel Roegl-Brunner
d29f71a92f Merge pull request #473 from community-scripts/fix/365
fix: detect app slug from LXC /usr/bin/update for port lookup
2026-01-29 13:28:36 +01:00
Michel Rögl-Brunner
aea14cda7e fix: detect app slug from LXC /usr/bin/update for port lookup
Resolve interface_port from community-scripts update file when hostname
differs from JSON slug (e.g. lxcpeanut vs peanut). Primary: slug parsed
from pct exec ... cat /usr/bin/update; fallback: hostname/suffix match.

Fixes #365
2026-01-29 13:26:29 +01:00
Michel Roegl-Brunner
4893ccda6e Merge pull request #472 from community-scripts/feat/406
feat: private/custom git repos - GitHub, GitLab, Bitbucket, custom
2026-01-29 13:11:54 +01:00
Michel Rögl-Brunner
a56c625b4f feat: private/custom git repos - GitHub, GitLab, Bitbucket, custom
- Add repository URL validation for GitHub, GitLab, Bitbucket, and custom hosts
- Add git provider layer (listDirectory, downloadRawFile) for all providers
- Wire githubJsonService and scriptDownloader to use provider; sync/download from any supported source
- Update GeneralSettingsModal placeholder and help text; .env.example and env schema for GITLAB_TOKEN, BITBUCKET_APP_PASSWORD

Closes #406
2026-01-29 13:08:28 +01:00
Michel Roegl-Brunner
54b2187f98 Merge pull request #471 from community-scripts/feat/419
feat: add TUN/TAP (VPN) option to container features in web GUI
2026-01-29 11:37:51 +01:00
Michel Rögl-Brunner
2f4e8606ed feat: add TUN/TAP (VPN) option to container features in web GUI
- Add var_tun to advanced defaults (default: no)
- Add TUN/TAP (VPN) dropdown in Container Features section for /dev/net/tun
- Enables Tailscale, WireGuard, OpenVPN in LXC containers via GUI
2026-01-29 11:32:23 +01:00
Michel Roegl-Brunner
ff5478dd72 Merge pull request #470 from community-scripts/feat/447
feat: Add Update all downloaded scripts button
2026-01-29 11:25:33 +01:00
Michel Rögl-Brunner
944a527972 fix: normalize failed item error type for TypeScript build 2026-01-29 11:22:43 +01:00
Michel Roegl-Brunner
c4479c1932 Merge pull request #469 from community-scripts/update_january_core
(core): Major update to upstream core functions with validation, IPv6 support, and Debian 13 fixes
2026-01-29 11:20:28 +01:00
CanbiZ (MickLesk)
9998e48621 fix(build.func): Fix typo - SD should use var_searchdomain not var_storage 2026-01-29 11:18:52 +01:00
Michel Rögl-Brunner
34eade3971 feat: add Update all downloaded scripts button
- Add bulk update button on Downloaded Scripts tab
- Use existing loadMultipleScripts API for all downloaded script slugs
- Confirmation modal before running (may take several minutes)
- Inline result: success/fail counts, hover for failed slugs
- Invalidate getAllDownloadedScripts and getScriptCardsWithCategories on success
2026-01-29 11:17:36 +01:00
CanbiZ (MickLesk)
82be47b959 refactor(core): Major update to core functions with validation, IPv6 support, and Debian 13 fixes
- alpine-tools.func: Complete rewrite with simplified structure and better error handling
- build.func: Add comprehensive validation functions (Container-ID, hostname, MAC, VLAN, MTU, IPv6, bridge, gateway, timezone, tags), storage space validation, improved password handling
- core.func: Add ensure_profile_loaded() and get_lxc_ip() functions, improved cleanup_lxc() with fallback error handling
- install.func: Fix Debian 13 LXC template bug (root owned by nobody), integrate get_lxc_ip()
- tools.func: Add IPv6 fallback support, improved NVIDIA GPU detection (including Open Kernel Module), Debian 13 Trixie support, new setup_meilisearch() function, completely reworked MariaDB setup with distribution package fallback
2026-01-29 11:01:12 +01:00
Michel Roegl-Brunner
9b77fc7ddb Merge pull request #468 from community-scripts/fix/465
fix: advanced modal SSH key discovery and tags delimiter
2026-01-29 10:27:56 +01:00
Michel Rögl-Brunner
db12ac4219 fix: advanced modal SSH key discovery and tags delimiter
- Allow ; as alternative to , for tags field (normalize on submit)
- Add GET /api/servers/[id]/discover-ssh-keys to find host SSH keys like native advanced mode
- Advanced modal: fetch discovered keys, dropdown to select + manual paste input
- Label/placeholder: Tags (comma or semicolon separated), e.g. tag1; tag2
2026-01-29 10:23:17 +01:00
Michel Roegl-Brunner
f66d1db861 Merge pull request #467 from community-scripts/fix/398
feat(ConfigurationModal): add Container ID (CTID) and DNS Search Domain to advanced install
2026-01-29 10:10:45 +01:00
Michel Rögl-Brunner
886c3e37ff feat(ConfigurationModal): add Container ID (CTID) and DNS Search Domain to advanced install
- Add optional Container ID (CTID) field at top of advanced form (var_ctid)
- Add DNS Search Domain field in Network section (var_searchdomain)
- Validate CTID when set: integer >= 100; empty = use next available ID
- Both fields optional; empty values omitted from env so script uses defaults
2026-01-29 10:05:14 +01:00
root
38deb09aa9 Add ctid option 2026-01-29 10:01:30 +01:00
dependabot[bot]
6d326dce1f build(deps): Bump next from 16.1.2 to 16.1.3 (#453)
Bumps [next](https://github.com/vercel/next.js) from 16.1.2 to 16.1.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.2...v16.1.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-16 21:19:21 +01:00
dependabot[bot]
6c8e177d3e build(deps-dev): Bump baseline-browser-mapping from 2.9.14 to 2.9.15 (#454)
Bumps [baseline-browser-mapping](https://github.com/web-platform-dx/baseline-browser-mapping) from 2.9.14 to 2.9.15.
- [Release notes](https://github.com/web-platform-dx/baseline-browser-mapping/releases)
- [Commits](https://github.com/web-platform-dx/baseline-browser-mapping/compare/v2.9.14...v2.9.15)

---
updated-dependencies:
- dependency-name: baseline-browser-mapping
  dependency-version: 2.9.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-16 21:19:11 +01:00
dependabot[bot]
879a548345 build(deps-dev): Bump eslint-config-next from 16.1.2 to 16.1.3 (#455)
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.1.2 to 16.1.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.3/packages/eslint-config-next)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-16 21:19:01 +01:00
dependabot[bot]
64cd81d5ba build(deps): Bump @tanstack/react-query from 5.90.17 to 5.90.18 (#456)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.90.17 to 5.90.18.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.90.18/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.90.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-16 21:18:49 +01:00
Michel Roegl-Brunner
61e75949c8 Merge pull request #452 from community-scripts/dependabot/npm_and_yarn/prettier-3.8.0 2026-01-15 21:02:14 +01:00
Michel Roegl-Brunner
a5d24bfad7 Merge pull request #451 from community-scripts/dependabot/npm_and_yarn/eslint-config-next-16.1.2 2026-01-15 21:02:01 +01:00
Michel Roegl-Brunner
04595c0093 Merge pull request #450 from community-scripts/dependabot/npm_and_yarn/next-16.1.2 2026-01-15 21:01:51 +01:00
Michel Roegl-Brunner
06fdb4889d Merge pull request #449 from community-scripts/dependabot/npm_and_yarn/types/node-24.10.9 2026-01-15 21:01:33 +01:00
dependabot[bot]
38d4f9f918 build(deps-dev): Bump prettier from 3.7.4 to 3.8.0
Bumps [prettier](https://github.com/prettier/prettier) from 3.7.4 to 3.8.0.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.4...3.8.0)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-15 19:57:36 +00:00
dependabot[bot]
63dc7c6983 build(deps-dev): Bump eslint-config-next from 16.1.1 to 16.1.2
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.1.1 to 16.1.2.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.2/packages/eslint-config-next)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-15 19:57:08 +00:00
dependabot[bot]
d57c6059fc build(deps): Bump next from 16.1.1 to 16.1.2
Bumps [next](https://github.com/vercel/next.js) from 16.1.1 to 16.1.2.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.1...v16.1.2)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-15 19:56:46 +00:00
dependabot[bot]
eb152f9fae build(deps-dev): Bump @types/node from 24.10.8 to 24.10.9
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.8 to 24.10.9.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.10.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-15 19:56:22 +00:00
Michel Roegl-Brunner
1a8e98fec0 Merge pull request #448 from community-scripts/dependabot/npm_and_yarn/tanstack/react-query-5.90.17 2026-01-15 14:24:10 +01:00
Michel Roegl-Brunner
83a1c7ea31 Merge pull request #446 from community-scripts/dependabot/npm_and_yarn/types/node-24.10.8 2026-01-15 14:23:57 +01:00
dependabot[bot]
79c63a7d3d build(deps): Bump @tanstack/react-query from 5.90.16 to 5.90.17
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.90.16 to 5.90.17.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.90.17/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.90.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-14 19:57:01 +00:00
dependabot[bot]
753721eee0 build(deps-dev): Bump @types/node from 24.10.4 to 24.10.8
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.4 to 24.10.8.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.10.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-13 20:01:03 +00:00
github-actions[bot]
09607296af chore: add VERSION v0.5.5 (#445)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-13 17:03:29 +00:00
CanbiZ (MickLesk)
c88040084a Improve server startup logging and update script fetching (#443)
Adds success and error logging to the Next.js app preparation process in server.js, including guidance for missing production builds. In versionRouter, always fetches the latest update.sh from GitHub before running updates, logging the outcome and falling back to the local script if fetching fails.
2026-01-13 18:03:01 +01:00
CanbiZ (MickLesk)
2573eb7314 github: improve PR template (#444) 2026-01-13 18:02:41 +01:00
github-actions[bot]
414c356446 chore: add VERSION v0.5.4 (#441)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-13 16:05:32 +00:00
CanbiZ
c38ded7a39 Update dependencies in package-lock.json
Upgraded multiple dependencies and devDependencies to their latest versions, including @prisma, @tanstack/react-query, next, eslint, typescript-eslint, and others. This ensures compatibility, security, and access to new features and bug fixes.
2026-01-13 17:03:21 +01:00
CanbiZ
0cfed84cd0 update package.json 2026-01-13 16:56:58 +01:00
CanbiZ (MickLesk)
9611bc9bcf Improve Node.js upgrade and service recovery in update.sh (#440)
Enhances the Node.js upgrade process by handling both .list and .sources files, updating the apt cache, and adding error handling for download and install failures. Introduces a function to re-enable and start the systemd service on failure to prevent user lockout, and ensures this is called during rollback and upgrade errors. Also refines Node.js version checks and build environment setup.
2026-01-13 16:53:37 +01:00
dependabot[bot]
6fe2a790fd build(deps): Bump @xterm/xterm from 5.5.0 to 6.0.0 (#431)
Bumps [@xterm/xterm](https://github.com/xtermjs/xterm.js) from 5.5.0 to 6.0.0.
- [Release notes](https://github.com/xtermjs/xterm.js/releases)
- [Commits](https://github.com/xtermjs/xterm.js/compare/5.5.0...6.0.0)

---
updated-dependencies:
- dependency-name: "@xterm/xterm"
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 16:47:18 +01:00
Bryan Lieberman
5ea71837e7 Fix nodejs update failure (#435)
* fix: update fails during NodeJS Version check

- Removed redundant NodeJS Update call

* fix: update fails after nodejs version check (#429)

- Removed redundant nodejs update call
2026-01-13 16:47:05 +01:00
dependabot[bot]
bf5ebc72b6 build(deps-dev): Bump @vitest/coverage-v8 from 4.0.15 to 4.0.16 (#434)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.0.15 to 4.0.16.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 16:46:25 +01:00
dependabot[bot]
a32c7bcbba build(deps): Bump lucide-react from 0.561.0 to 0.562.0 (#433)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.561.0 to 0.562.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.562.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.562.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 16:46:14 +01:00
dependabot[bot]
98c6e79db6 build(deps-dev): Bump @testing-library/react from 16.3.0 to 16.3.1 (#432)
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 16.3.0 to 16.3.1.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.0...v16.3.1)

---
updated-dependencies:
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 16:46:05 +01:00
dependabot[bot]
c962a9cd5a build(deps): Bump @xterm/addon-web-links from 0.11.0 to 0.12.0 (#430)
Bumps [@xterm/addon-web-links](https://github.com/xtermjs/xterm.js) from 0.11.0 to 0.12.0.
- [Release notes](https://github.com/xtermjs/xterm.js/releases)
- [Commits](https://github.com/xtermjs/xterm.js/compare/0.11...0.12)

---
updated-dependencies:
- dependency-name: "@xterm/addon-web-links"
  dependency-version: 0.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 16:45:53 +01:00
CanbiZ
5d20a6d694 fix: allow update without existing database file
Database verification now allows missing database files for new
installations. The app will create the database automatically
via Prisma migrations on first start.
2026-01-07 20:54:16 +01:00
github-actions[bot]
cb4e8c543a chore: add VERSION v0.5.3 (#428)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-07 19:52:06 +00:00
CanbiZ (MickLesk)
2ba213de49 fix: pct create fails with malformed arguments (#423) (#427)
- Fix NS/MTU/MAC/VLAN/SD variables missing proper prefixes in base_settings()
  Variables were passed as raw values instead of formatted pct options
  (e.g., '192.168.1.1' instead of '-nameserver=192.168.1.1')

- Strip spaces from nameserver values to prevent 'too many arguments' error
  Multiple DNS servers must be comma-separated without spaces

- Auto-create database directory before Prisma initialization
  Fixes 'Cannot open database because directory does not exist' error
  for manual Git installations
2026-01-07 20:50:51 +01:00
CanbiZ (MickLesk)
849aabb575 update footer to 2026 (#426) 2026-01-07 20:45:45 +01:00
CanbiZ (MickLesk)
dd33df2033 Update Core to 2026 State-Of-The Art (ProxmoxVE Upstream Merge) (#425)
* update core.func

* Add advanced container features and IP range scanning

Introduces support for scanning and assigning the first free IP from a user-specified range, and expands advanced LXC container settings to include GPU passthrough, TUN/TAP, nesting, keyctl, mknod, timezone, protection, and APT cacher options. Refactors advanced_settings wizard to support these new features, updates variable handling and defaults, and improves summary and output formatting. Also enhances SSH key configuration, storage/template validation, and GPU passthrough logic.

* update install.func

* Enhance hardware acceleration and MariaDB setup

Refactors and expands the hardware acceleration setup to support multiple GPU types (Intel, AMD, NVIDIA), adds user selection for GPU configuration, and improves driver installation logic for Debian and Ubuntu. Adds runtime directory persistence for MariaDB using tmpfiles.d to ensure /run/mysqld exists after reboot. Includes minor robustness improvements and error handling throughout the script.

* Update error-handler.func

* Update copyright years to 2026 in core scripts

Updated the copyright year from 2025 to 2026 in alpine-install.func, api.func, and cloud-init.func to reflect the new year. No functional changes were made.
2026-01-07 20:43:49 +01:00
dependabot[bot]
94eb2820fd build(deps): Bump @trpc/react-query from 11.7.2 to 11.8.0 (#417)
Bumps [@trpc/react-query](https://github.com/trpc/trpc/tree/HEAD/packages/react) from 11.7.2 to 11.8.0.
- [Release notes](https://github.com/trpc/trpc/releases)
- [Commits](https://github.com/trpc/trpc/commits/v11.8.0/packages/react)

---
updated-dependencies:
- dependency-name: "@trpc/react-query"
  dependency-version: 11.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-07 20:35:00 +01:00
dependabot[bot]
e49708770c build(deps-dev): Bump @types/node from 24.10.1 to 24.10.4 (#418)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.1 to 24.10.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.10.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-07 20:33:48 +01:00
dependabot[bot]
5eafa01843 build(deps-dev): Bump @tailwindcss/postcss from 4.1.17 to 4.1.18 (#416)
Bumps [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) from 4.1.17 to 4.1.18.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/packages/@tailwindcss-postcss)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-07 20:33:35 +01:00
dependabot[bot]
0c1477e087 build(deps-dev): Bump eslint-config-next from 16.0.7 to 16.1.0 (#415)
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.0.7 to 16.1.0.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.0/packages/eslint-config-next)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-07 20:33:28 +01:00
dependabot[bot]
ef73d98873 build(deps): Bump @trpc/server (#413)
Bumps the npm_and_yarn group with 1 update in the / directory: [@trpc/server](https://github.com/trpc/trpc/tree/HEAD/packages/server).


Updates `@trpc/server` from 11.7.2 to 11.8.0
- [Release notes](https://github.com/trpc/trpc/releases)
- [Commits](https://github.com/trpc/trpc/commits/v11.8.0/packages/server)

---
updated-dependencies:
- dependency-name: "@trpc/server"
  dependency-version: 11.8.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-07 20:33:20 +01:00
Slaviša Arežina
ec92c0ea6d Merge pull request #421 from zeosamaster/patch-1 2025-12-30 13:51:57 +01:00
Fábio Matias
ee14b89868 Fix directory path in README after cloning 2025-12-30 11:46:33 +00:00
Michel Roegl-Brunner
be68160cd9 Merge pull request #411 from community-scripts/dependabot/npm_and_yarn/t3-oss/env-nextjs-0.13.10 2025-12-18 19:43:58 +01:00
Michel Roegl-Brunner
dbc15b1bc3 Merge pull request #410 from community-scripts/dependabot/npm_and_yarn/jsdom-27.3.0 2025-12-18 19:43:44 +01:00
Michel Roegl-Brunner
dc6ce16e5a Merge pull request #409 from community-scripts/dependabot/npm_and_yarn/tanstack/react-query-5.90.12 2025-12-18 19:43:33 +01:00
Michel Roegl-Brunner
0c9d4ad6e2 Merge pull request #408 from community-scripts/dependabot/npm_and_yarn/react-dom-19.2.3 2025-12-18 19:43:20 +01:00
dependabot[bot]
13d57b77d4 build(deps): Bump @t3-oss/env-nextjs from 0.13.8 to 0.13.10
Bumps [@t3-oss/env-nextjs](https://github.com/t3-oss/t3-env/tree/HEAD/packages/nextjs) from 0.13.8 to 0.13.10.
- [Release notes](https://github.com/t3-oss/t3-env/releases)
- [Changelog](https://github.com/t3-oss/t3-env/blob/main/packages/nextjs/CHANGELOG.md)
- [Commits](https://github.com/t3-oss/t3-env/commits/@t3-oss/env-nextjs@0.13.10/packages/nextjs)

---
updated-dependencies:
- dependency-name: "@t3-oss/env-nextjs"
  dependency-version: 0.13.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 19:25:26 +00:00
dependabot[bot]
f9e5bd5bf0 build(deps-dev): Bump jsdom from 27.2.0 to 27.3.0
Bumps [jsdom](https://github.com/jsdom/jsdom) from 27.2.0 to 27.3.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/27.2.0...27.3.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 27.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 19:25:15 +00:00
dependabot[bot]
adf2b06efa build(deps): Bump @tanstack/react-query from 5.90.11 to 5.90.12
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.90.11 to 5.90.12.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.90.12/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.90.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 19:25:03 +00:00
dependabot[bot]
80e3966e4e build(deps): Bump react-dom from 19.2.1 to 19.2.3
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.1 to 19.2.3.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 19:24:52 +00:00
Michel Roegl-Brunner
3662a057dc Merge pull request #401 from community-scripts/dependabot/npm_and_yarn/tailwindcss-4.1.18 2025-12-14 11:10:27 +01:00
Michel Roegl-Brunner
bdf336f9bf Merge pull request #400 from community-scripts/dependabot/npm_and_yarn/react-19.2.3 2025-12-14 11:10:17 +01:00
Michel Roegl-Brunner
f6c310fa22 Merge pull request #399 from community-scripts/dependabot/npm_and_yarn/vitejs/plugin-react-5.1.2 2025-12-14 11:10:04 +01:00
Michel Roegl-Brunner
d658894b7f Merge pull request #397 from community-scripts/dependabot/npm_and_yarn/npm_and_yarn-3b9adafb77 2025-12-14 11:09:56 +01:00
dependabot[bot]
783744b497 build(deps-dev): Bump tailwindcss from 4.1.17 to 4.1.18
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.1.17 to 4.1.18.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/packages/tailwindcss)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-12 19:49:47 +00:00
dependabot[bot]
de9ac41f76 build(deps): Bump react from 19.2.1 to 19.2.3
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.1 to 19.2.3.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-12 19:49:38 +00:00
dependabot[bot]
060202e557 build(deps-dev): Bump @vitejs/plugin-react from 5.1.1 to 5.1.2
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.1.1 to 5.1.2.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.2/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-12 19:49:27 +00:00
dependabot[bot]
8d45ac14cc build(deps): Bump next in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 16.0.9 to 16.0.10
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.9...v16.0.10)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.0.10
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-12 17:26:19 +00:00
CanbiZ
47ee2247c8 fix for code scanning alert no. 4: Insecure randomness (#396)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-12 11:45:43 +01:00
dependabot[bot]
c16c8d54db build(deps): Bump lucide-react from 0.555.0 to 0.556.0 (#392)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.555.0 to 0.556.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.556.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.556.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 11:29:10 +01:00
dependabot[bot]
3e669a0739 build(deps): Bump jsonwebtoken from 9.0.2 to 9.0.3 (#390)
Bumps [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) from 9.0.2 to 9.0.3.
- [Changelog](https://github.com/auth0/node-jsonwebtoken/blob/master/CHANGELOG.md)
- [Commits](https://github.com/auth0/node-jsonwebtoken/compare/v9.0.2...v9.0.3)

---
updated-dependencies:
- dependency-name: jsonwebtoken
  dependency-version: 9.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 11:27:47 +01:00
dependabot[bot]
02e175c8a0 build(deps-dev): Bump eslint-config-next from 16.0.6 to 16.0.7 (#391)
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.0.6 to 16.0.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.0.7/packages/eslint-config-next)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 11:27:41 +01:00
dependabot[bot]
b4e98e7624 build(deps-dev): Bump prettier from 3.7.3 to 3.7.4 (#393)
Bumps [prettier](https://github.com/prettier/prettier) from 3.7.3 to 3.7.4.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.3...3.7.4)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.7.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 11:27:23 +01:00
dependabot[bot]
2392529092 build(deps-dev): Bump baseline-browser-mapping from 2.8.32 to 2.9.3 (#394)
Bumps [baseline-browser-mapping](https://github.com/web-platform-dx/baseline-browser-mapping) from 2.8.32 to 2.9.3.
- [Release notes](https://github.com/web-platform-dx/baseline-browser-mapping/releases)
- [Commits](https://github.com/web-platform-dx/baseline-browser-mapping/compare/v2.8.32...v2.9.3)

---
updated-dependencies:
- dependency-name: baseline-browser-mapping
  dependency-version: 2.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 11:27:17 +01:00
dependabot[bot]
f9f5772d92 build(deps): Bump next in the npm_and_yarn group across 1 directory (#395)
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 16.0.7 to 16.0.9
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.7...v16.0.9)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.0.9
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 11:27:11 +01:00
github-actions[bot]
4267d7340e chore: add VERSION v0.5.2 (#389)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-05 15:13:42 +00:00
Michel Roegl-Brunner
dcf923551b Merge pull request #388 from community-scripts/feat/use_new_core_features
feat: Add default and advanced install method selection
2025-12-05 16:05:33 +01:00
Michel Roegl-Brunner
69a5ac3a56 Nump core 2025-12-05 16:03:28 +01:00
Michel Roegl-Brunner
7b8c1ebdf1 feat: Add default and advanced install method selection
- Add ConfigurationModal component for selecting default or advanced installation mode
- Default mode: Uses predefined defaults with minimal user input (hostname from slug, vmbr0, dhcp, etc.)
- Advanced mode: Full configuration modal with all environment variables customizable
- Add support for IPv4 CIDR input when network mode is 'static'
- Add support for IPv6 static address input when IPv6 method is 'static'
- Implement password formatting as '-password <password>' for build.func compatibility
- Auto-enable SSH when password or SSH keys are provided
- Add storage selection dropdowns filtered by server node assignment
- Pass environment variables through entire execution stack (frontend -> WebSocket -> SSH/local execution)
- Add mode environment variable (always set to 'default' for script execution)
- Update ExecutionModeModal to show 'Advanced (Beta)' option
2025-12-05 15:53:50 +01:00
dependabot[bot]
580b623939 build(deps): Bump @prisma/adapter-better-sqlite3 from 7.0.1 to 7.1.0 (#382)
Bumps [@prisma/adapter-better-sqlite3](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-better-sqlite3) from 7.0.1 to 7.1.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.1.0/packages/adapter-better-sqlite3)

---
updated-dependencies:
- dependency-name: "@prisma/adapter-better-sqlite3"
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 22:01:38 +01:00
dependabot[bot]
ac21fbb181 build(deps): Bump jws in the npm_and_yarn group across 1 directory (#386)
Bumps the npm_and_yarn group with 1 update in the / directory: [jws](https://github.com/brianloveswords/node-jws).


Updates `jws` from 3.2.2 to 3.2.3
- [Release notes](https://github.com/brianloveswords/node-jws/releases)
- [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: jws
  dependency-version: 3.2.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 22:01:27 +01:00
Michel Roegl-Brunner
588ae65dfd Merge pull request #380 from community-scripts/dependabot/npm_and_yarn/next-16.0.7 2025-12-04 21:42:15 +01:00
Michel Roegl-Brunner
30acba39a5 Merge pull request #381 from community-scripts/dependabot/npm_and_yarn/react-dom-19.2.1 2025-12-04 21:42:03 +01:00
Michel Roegl-Brunner
3a5bb3dc45 Merge pull request #383 from community-scripts/dependabot/npm_and_yarn/prisma/client-7.1.0 2025-12-04 21:41:41 +01:00
Michel Roegl-Brunner
f42c0d956e Merge pull request #384 from community-scripts/dependabot/npm_and_yarn/npm_and_yarn-494b550551 2025-12-04 21:41:29 +01:00
Michel Roegl-Brunner
0ed13fcf0f Fix linter errors: use Record type, remove unused function, fix floating promises and unsafe types 2025-12-04 14:36:54 +01:00
dependabot[bot]
afc87910e6 build(deps): Bump react-dom from 19.2.0 to 19.2.1
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.0 to 19.2.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.1/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-04 09:11:09 +00:00
Michel Roegl-Brunner
b97eca9620 Merge pull request #385 from community-scripts/fix/source-calls-path-resolution
Bump all .func files in /core to be inline with the Main repo.
2025-12-04 10:09:56 +01:00
Michel Roegl-Brunner
f4aa8661c4 Bump all .func scripts to be inline with the new core 2025-12-04 10:06:17 +01:00
dependabot[bot]
8f0ae3a341 build(deps): Bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [next](https://github.com/vercel/next.js) and [hono](https://github.com/honojs/hono).


Updates `next` from 16.0.6 to 16.0.7
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.6...v16.0.7)

Updates `hono` from 4.7.10 to 4.10.6
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.7.10...v4.10.6)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.0.7
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: hono
  dependency-version: 4.10.6
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 20:49:31 +00:00
dependabot[bot]
b5450bd221 build(deps): Bump @prisma/client from 7.0.1 to 7.1.0
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 7.0.1 to 7.1.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.1.0/packages/client)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 19:28:10 +00:00
dependabot[bot]
88dbe4ea85 build(deps): Bump next from 16.0.6 to 16.0.7
Bumps [next](https://github.com/vercel/next.js) from 16.0.6 to 16.0.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.6...v16.0.7)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 19:27:21 +00:00
Michel Roegl-Brunner
f0b5956b54 Merge pull request #374 from community-scripts/dependabot/npm_and_yarn/next-16.0.6
build(deps): Bump next from 16.0.5 to 16.0.6
2025-12-03 10:31:02 +01:00
Michel Roegl-Brunner
e5000246b3 Merge pull request #375 from community-scripts/dependabot/npm_and_yarn/vitest/coverage-v8-4.0.15
build(deps-dev): Bump @vitest/coverage-v8 from 4.0.14 to 4.0.15
2025-12-03 10:30:55 +01:00
Michel Roegl-Brunner
9dacf1e530 Merge pull request #376 from community-scripts/dependabot/npm_and_yarn/typescript-eslint-8.48.1
build(deps-dev): Bump typescript-eslint from 8.48.0 to 8.48.1
2025-12-03 10:30:48 +01:00
dependabot[bot]
f248ed2875 build(deps-dev): Bump typescript-eslint from 8.48.0 to 8.48.1
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.48.0 to 8.48.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.48.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.48.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 19:39:14 +00:00
dependabot[bot]
4e6295885b build(deps-dev): Bump @vitest/coverage-v8 from 4.0.14 to 4.0.15
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.0.14 to 4.0.15.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.15/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 19:38:54 +00:00
dependabot[bot]
2357232cae build(deps): Bump next from 16.0.5 to 16.0.6
Bumps [next](https://github.com/vercel/next.js) from 16.0.5 to 16.0.6.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.5...v16.0.6)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 19:38:31 +00:00
Michel Roegl-Brunner
39d8115dda Merge pull request #369 from community-scripts/dependabot/npm_and_yarn/prettier-plugin-tailwindcss-0.7.2 2025-12-02 18:39:34 +01:00
dependabot[bot]
bd71b04a9d build(deps-dev): Bump prettier-plugin-tailwindcss from 0.7.1 to 0.7.2
Bumps [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) from 0.7.1 to 0.7.2.
- [Release notes](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.7.1...v0.7.2)

---
updated-dependencies:
- dependency-name: prettier-plugin-tailwindcss
  dependency-version: 0.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 10:12:17 +00:00
Michel Roegl-Brunner
c0b03cd832 Merge pull request #370 from community-scripts/dependabot/npm_and_yarn/tsx-4.21.0
build(deps-dev): Bump tsx from 4.20.6 to 4.21.0
2025-12-02 11:11:23 +01:00
Michel Roegl-Brunner
9b7c740145 Merge pull request #371 from community-scripts/dependabot/npm_and_yarn/prettier-3.7.3
build(deps-dev): Bump prettier from 3.7.1 to 3.7.3
2025-12-02 11:11:16 +01:00
Michel Roegl-Brunner
4f929fb8da Merge pull request #372 from community-scripts/dependabot/npm_and_yarn/eslint-config-next-16.0.6
build(deps-dev): Bump eslint-config-next from 16.0.5 to 16.0.6
2025-12-02 11:11:06 +01:00
Michel Roegl-Brunner
24ee87d14e Merge pull request #373 from community-scripts/dependabot/npm_and_yarn/better-sqlite3-12.5.0
build(deps): Bump better-sqlite3 from 12.4.6 to 12.5.0
2025-12-02 11:10:58 +01:00
dependabot[bot]
55862628fb build(deps): Bump better-sqlite3 from 12.4.6 to 12.5.0
Bumps [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) from 12.4.6 to 12.5.0.
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.4.6...v12.5.0)

---
updated-dependencies:
- dependency-name: better-sqlite3
  dependency-version: 12.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 22:42:45 +00:00
dependabot[bot]
fbd731f020 build(deps-dev): Bump eslint-config-next from 16.0.5 to 16.0.6
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.0.5 to 16.0.6.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.0.6/packages/eslint-config-next)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 22:42:27 +00:00
dependabot[bot]
a8b750ad75 build(deps-dev): Bump prettier from 3.7.1 to 3.7.3
Bumps [prettier](https://github.com/prettier/prettier) from 3.7.1 to 3.7.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.1...3.7.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.7.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 22:42:03 +00:00
dependabot[bot]
1054b6d2f5 build(deps-dev): Bump tsx from 4.20.6 to 4.21.0
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.20.6 to 4.21.0.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.20.6...v4.21.0)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.21.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 22:41:52 +00:00
github-actions[bot]
669ce41c2e chore: add VERSION v0.5.1 (#366)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-01 09:35:27 +00:00
Michel Roegl-Brunner
7c4683012f Update update.sh 2025-12-01 10:29:41 +01:00
github-actions[bot]
cfcdc1e342 chore: add VERSION v0.5.1 (#361)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-29 15:57:44 +00:00
Michel Roegl-Brunner
07cf03a408 Merge pull request #360 from community-scripts/feat/clone_lxc_vm
feat: Add VM/LXC cloning functionality
2025-11-29 16:56:48 +01:00
Michel Roegl-Brunner
dd17d2cbec feat: Add VM/LXC cloning functionality
- Add CloneCountInputModal component for specifying clone count
- Implement clone handlers and state management in InstalledScriptsTab
- Add clone menu item to ScriptInstallationCard
- Extend StorageSelectionModal to support clone storage selection (rootdir only)
- Add clone terminal support to Terminal component
- Implement startSSHCloneExecution in server.js with sequential ID retrieval
- Add clone-related API endpoints (getClusterNextId, getContainerType, getCloneStorages, generateCloneHostnames, executeClone, addClonedContainerToDatabase)
- Integrate with VM/LXC detection from main branch
- Fix storage fetching to use correct serverId parameter
- Fix clone execution to pass storage parameter correctly
- Remove unused eslint-disable comments
2025-11-29 16:53:58 +01:00
Michel Roegl-Brunner
f3d14c6746 Merge pull request #359 from community-scripts/fix/352
fix: align toggle switches in repository settings
2025-11-29 16:14:07 +01:00
Michel Roegl-Brunner
447332e558 fix: align toggle switches in repository settings
- Remove fixed label width from Toggle component
- Move delete button to left of toggle switches
- Add matching border/padding to 'Enable after adding' section to align with repository items
- Ensure all toggles have consistent right-side alignment
2025-11-29 16:12:20 +01:00
Michel Roegl-Brunner
9bbc19ae44 Merge pull request #358 from community-scripts/fix/357_356
fix: Add dynamic text to container control loading modal
2025-11-29 16:00:49 +01:00
Michel Roegl-Brunner
5564ae0393 fix: add dynamic text to container control loading modal
- Update LoadingModal to display action text (Starting/Stopping LXC/VM)
- Update handleStartStop to include container type (LXC/VM) in action text
- Show clear feedback when starting or stopping containers
2025-11-29 15:58:30 +01:00
Michel Roegl-Brunner
93d7842f6c feat: implement batch container type detection for performance optimization
- Add batchDetectContainerTypes() helper function that uses pct list and qm list to detect all container types in 2 SSH calls per server
- Update getAllInstalledScripts to use batch detection instead of individual isVM() calls per script
- Update getInstalledScriptsByServer to use batch detection for single server
- Update database queries to include lxc_config relation for fallback detection
- Fix isVM() function to properly default to LXC when VM config doesn't exist
- Significantly improves performance: reduces from N SSH calls per script to 2 SSH calls per server
2025-11-29 15:55:43 +01:00
Michel Roegl-Brunner
84c02048bc Fix a false detection as a VM when it is a LXC 2025-11-29 15:41:49 +01:00
github-actions[bot]
66a3bb3203 chore: add VERSION v0.5.0 (#355)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-28 14:04:05 +00:00
Michel Roegl-Brunner
0da802be42 Merge pull request #354 from community-scripts/bugfixing_bumps
Add TypeScript Runtime Support and add Prisma 7 Compatibility
2025-11-28 14:56:43 +01:00
CanbiZ
5bc3933d11 fix gh action runner 2025-11-28 14:48:38 +01:00
CanbiZ
1c6d1ac120 fix gh action 2025-11-28 14:48:06 +01:00
CanbiZ
ba1e6478d7 Update package-lock.json - add tsx to lock file 2025-11-28 14:46:18 +01:00
CanbiZ
e3af248456 Merge main into bugfixing_bumps - keep bugfixing_bumps versions (Prisma 7, tsx support) 2025-11-28 14:43:25 +01:00
CanbiZ
43bafb610f Add initializeRepositories to autoSyncInit.ts 2025-11-28 14:34:28 +01:00
CanbiZ
8e22568efb Add detailed logging for autoSyncInit dynamic import and function checks 2025-11-28 14:33:11 +01:00
CanbiZ
6bb9ed5182 Use dynamic import for autoSyncInit to avoid tsx caching issues 2025-11-28 14:30:43 +01:00
CanbiZ
b6c3954f98 Fix start script to use tsx for Prisma 7 TypeScript support 2025-11-28 14:14:16 +01:00
CanbiZ
f73b303172 Use node --import tsx for better TypeScript support 2025-11-28 14:11:42 +01:00
CanbiZ
50d066669e Fix db.js - add .ts extension for Prisma client import 2025-11-28 14:11:09 +01:00
CanbiZ
68541c0046 Use tsx to run server.js - enables TypeScript imports for Prisma 7 2025-11-28 14:09:25 +01:00
CanbiZ
644222e958 Fix db.js - require index.js explicitly 2025-11-28 14:08:39 +01:00
CanbiZ
31a5fd97d4 Fix db.js - use absolute path to .prisma/client 2025-11-28 14:07:54 +01:00
CanbiZ
b54fbf15f6 Fix db.js - use createRequire to load .prisma/client CJS module 2025-11-28 14:07:22 +01:00
CanbiZ
a787e60e7c Fix db.js - import from .prisma/client runtime location 2025-11-28 14:06:35 +01:00
CanbiZ
1e250306dc Fix db.js - use CommonJS-style import for @prisma/client 2025-11-28 14:05:37 +01:00
CanbiZ
d64a296ebe Fix db.js - import PrismaClient from @prisma/client for Node ESM compatibility 2025-11-28 14:04:58 +01:00
CanbiZ
691b27c924 Fix db.js import - add .js extension for Prisma client 2025-11-28 14:04:05 +01:00
CanbiZ
dbc591aa63 Add JS wrappers for ESM compatibility (repositoryService, githubJsonService) and fix .js import extensions 2025-11-28 14:01:18 +01:00
CanbiZ
5ea6828f8c Fix ESM import in database-prisma.js - add .js extension 2025-11-28 13:58:05 +01:00
CanbiZ
3dabacd055 Revert "Auto-initialize default repositories on first API call"
This reverts commit aebc8a6171.
2025-11-28 13:56:29 +01:00
CanbiZ
e8ee829577 Add explicit type annotations and return types
This commit adds TypeScript type definitions for database entities and updates all methods in DatabaseServicePrisma to use explicit type annotations and return types. This improves type safety, code clarity, and maintainability by ensuring consistent return values and better integration with Prisma-generated types.
2025-11-28 13:50:44 +01:00
CanbiZ
aebc8a6171 Auto-initialize default repositories on first API call 2025-11-28 13:41:21 +01:00
CanbiZ
c5db169441 Fix login race condition: don't call checkAuth after successful login 2025-11-28 13:34:51 +01:00
CanbiZ
bef5bef875 Remove .js extensions from dynamic imports 2025-11-28 13:32:51 +01:00
CanbiZ
3a4f86942f Remove circular import wrapper files (githubJsonService.js, localScripts.js) 2025-11-28 13:30:17 +01:00
CanbiZ
94eb772467 Fix cross-origin cookie issues: use lax SameSite and add CORS headers 2025-11-28 13:28:53 +01:00
CanbiZ
3a2a1b2cd6 Add datasource property to Prisma config for migrate/db push support 2025-11-28 13:25:33 +01:00
CanbiZ
69c10b05ac Ensure recursive creation of ssh-keys directory
Updated the initialization logic to use the 'recursive' option when creating the data/ssh-keys directory, ensuring parent directories are created if they do not exist.
2025-11-28 13:23:31 +01:00
Michel Roegl-Brunner
7833d5d408 Fix type errors 2025-11-28 13:21:37 +01:00
CanbiZ
e0baa79d6b Remove file extensions from import statements
Updated import statements across several server files to omit explicit file extensions. This improves compatibility with module resolution and aligns with common import practices.
2025-11-28 13:21:03 +01:00
CanbiZ
737c9c94f3 Fix repositoryService import extension in autoSyncInit.js 2025-11-28 13:19:53 +01:00
CanbiZ
c57586acae Fix Prisma import path in db.js and update allowedDevOrigins format 2025-11-28 13:18:29 +01:00
CanbiZ
74030b5806 Refactor lint comments and minor code improvements
Removed or updated unnecessary eslint-disable comments across several server and service files to improve code clarity. Fixed import paths and added TypeScript ignore comments where needed for compatibility. Minor formatting adjustments were made for readability.
2025-11-28 13:13:01 +01:00
CanbiZ
cc276ddff3 Improve script downloader and auto-sync services
Added detailed JSDoc comments and type annotations to ScriptDownloaderService for better maintainability and clarity. Refactored initialization logic to always set scriptsDirectory and repoUrl. Enhanced autoSyncService to specify cron job timezone and options. Updated PrismaClient import path for compatibility with generated client structure.
2025-11-28 13:10:20 +01:00
CanbiZ
375c551a3a Update Prisma to v7 and add better-sqlite3 adapter
Upgraded Prisma and related dependencies to version 7.0.1 and added @prisma/adapter-better-sqlite3 and better-sqlite3 to support the new adapter. This enables improved SQLite integration and compatibility with the latest Prisma features.
2025-11-28 13:06:40 +01:00
CanbiZ
e3e4556f83 Add type annotations and improve script services
Enhanced type safety and documentation in several files, including adding explicit type annotations for script objects and function parameters. Improved error handling and code clarity in scriptDownloader.js, and updated autoSyncService.js to remove unnecessary cron job options. Refactored prisma.config.ts for schema configuration and updated server.js to support backup storage and improve parameter defaults.
2025-11-28 13:06:16 +01:00
CanbiZ
7fa132e09c Update Prisma adapter and type annotations
Replaces usage of PrismaBetterSQLite3 with PrismaBetterSqlite3 for consistency and correct casing. Updates type annotations in several components and API router for improved type safety. Also adjusts PrismaClient import paths in db files.
2025-11-28 13:02:34 +01:00
CanbiZ
1a1dbe6975 upgrade betterauthsql 2025-11-28 12:57:56 +01:00
CanbiZ
1a5881c935 Migration to Prisma 7 2025-11-28 12:55:32 +01:00
CanbiZ
2d7176914e Enable JS type checking in tsconfig.json
Set `checkJs` to true to allow type checking for JavaScript files. Updated include/exclude patterns to support JS and CJS files in the project.
2025-11-28 12:51:07 +01:00
CanbiZ
987ac3da1b Fix: Remove .js extensions from TypeScript imports for Next.js bundler 2025-11-28 12:48:10 +01:00
CanbiZ
03e31d66a7 Refactor type usage and improve data normalization
Updated several components to use explicit TypeScript types for better type safety. Normalized appriseUrls to always be an array in auto-sync settings API. Improved handling of optional server_id in BackupsTab and adjusted IP detection logic in InstalledScriptsTab. Removed unnecessary eslint-disable comments and improved code clarity in various places.
2025-11-28 12:47:09 +01:00
CanbiZ
7547dff67d Fix type annotations and module imports
Added explicit type annotations to array mapping functions for better type safety. Updated incorrect TypeScript import extensions from .ts to .js for compatibility. Ensured default values for optional parameters and improved code clarity in API routers.
2025-11-28 12:29:15 +01:00
CanbiZ
1945b14694 Update ESLint config to use FlatCompat and expand ignores
Switched to using FlatCompat for ESLint configuration and extended the ignore list to include 'next-env.d.ts', 'postcss.config.js', and 'prettier.config.js'. This improves compatibility and prevents linting of config and environment files.
2025-11-28 12:23:47 +01:00
CanbiZ
ec23600861 Switch ESLint config to eslint-config-next/core-web-vitals
Replaces @eslint/eslintrc and FlatCompat with eslint-config-next/core-web-vitals for ESLint configuration. Updates linting scripts in package.json to use eslint directly instead of next lint. Removes @eslint/eslintrc from devDependencies.
2025-11-28 12:20:53 +01:00
CanbiZ
41a9c0ae11 Switch to ESLint CLI (Deprecation of ESLint)
Added and updated eslint-disable comments in repositoryService, restoreService, and storageService to cover additional TypeScript rules. Simplified error handling by removing unused variables and catch parameters, and removed unused imports and variables in restoreService for cleaner code.
2025-11-28 12:19:47 +01:00
CanbiZ
c266c4cb3c Refactor InstalledScriptsTab for code style consistency
Updated InstalledScriptsTab.tsx to use double quotes and consistent formatting throughout the file. Improved type annotations, code readability, and standardized state initialization and mutation usage. No functional changes were made; this is a style and maintainability refactor.
2025-11-28 12:16:18 +01:00
CanbiZ
b5bce88398 Refactor InstalledScriptsTab for code style consistency
Standardizes quote usage, formatting, and code style in InstalledScriptsTab.tsx. Improves readability and maintains consistent conventions across the file without changing logic or functionality.
2025-11-28 12:15:27 +01:00
CanbiZ
48cf86a449 Refactor nullish checks and add type safety
Replaces many uses of logical OR (||) with nullish coalescing (??) for more accurate handling of undefined/null values. Adds explicit type annotations and interfaces to improve type safety, especially in API routes and server-side code. Updates SSH connection test handling and config parsing in installedScripts router for better reliability. Minor fixes to deduplication logic, cookie handling, and error reporting.
2025-11-28 12:10:15 +01:00
CanbiZ
d40aeb6c82 Refactor scripts grid and filter handling for robustness
Improves type safety and normalization in filter, repository, and script status handling across multiple components. Refactors ScriptsGrid for better readability, deduplication, and error messaging, and updates UI markup for consistency. Also adds explicit types for auto-sync settings and ensures string conversion for credential fields.
2025-11-28 11:58:38 +01:00
CanbiZ
9c759ba99b fix: ESLint/TypeScript fixes - nullish coalescing, regexp-exec, optional-chain, unescaped-entities, unused-vars, type-safety 2025-11-28 11:53:04 +01:00
root
f467b9ad7b fix vulnerabilities 2025-11-28 11:49:39 +01:00
Michel Roegl-Brunner
7fe2a8b453 Merge pull request #353 from community-scripts/fix/vm_detection
Add VM status check and UI improvements
2025-11-28 11:46:39 +01:00
Michel Roegl-Brunner
5274737ab8 Add VM status check and UI improvements
- Add VM status checking using qm status command
- Hide update button for VMs (only show for LXC containers)
- Hide shell button for VMs (only show for LXC containers)
- Hide LXC Settings option for VMs
- Display VM/LXC indicator badges in table before script names
- Update statistics cards to differentiate between LXC and VMs
- Update container control to support both pct (LXC) and qm (VM) commands
- Improve status parsing to handle both container types
2025-11-28 11:44:58 +01:00
CanbiZ
40805f39f7 Update dependencies and adjust TypeScript JSX setting
Upgraded multiple dependencies and devDependencies in package.json to their latest versions for improved stability and features. Changed the TypeScript 'jsx' compiler option from 'react-jsx' to 'preserve' in tsconfig.json to better align with project requirements.
2025-11-28 11:44:26 +01:00
Michel Roegl-Brunner
f9af7536d0 Update Confirmation modal 2025-11-28 11:27:12 +01:00
Michel Roegl-Brunner
0d39a9bbd0 Update update.sh to inlcude node update 2025-11-26 11:33:26 +01:00
Michel Roegl-Brunner
66f8a84260 Various small fixes (#349)
* Fix script viewer to support vm/ and tools/ scripts

- Update ScriptDetailModal to extract scriptName from any path (ct/, vm/, tools/)
- Refactor TextViewer to use actual script paths from install_methods
- Remove hardcoded path assumptions and use dynamic script paths
- Only show Install Script tab for ct/ scripts that have install scripts
- Rename CT Script tab to Script for better clarity

* Fix downloaded scripts count to include vm/ and tools/ scripts

- Update matching logic to use same robust approach as DownloadedScriptsTab
- Add normalized slug matching to handle filename-based slugs vs JSON slugs
- Add multiple fallback matching strategies for better script detection
- Fixes issue where scripts in vm/ and tools/ directories weren't being counted

* Filter categories to only show those with scripts

- Add filter to exclude categories with count 0 from category sidebar
- Only categories with at least one script will be displayed
- Reduces UI clutter by hiding empty categories

* Fix intermittent page reloads from VersionDisplay reconnect logic

- Add guards to prevent reload when not updating
- Use refs to track isUpdating and isNetworkError state in interval callbacks
- Add hasReloadedRef flag to prevent multiple reloads
- Clear reconnect interval when update completes or component unmounts
- Only start reconnect attempts when actually updating
- Prevents false positive reloads when server responds normally

* Fix Next.js HMR WebSocket and static asset handling

- Add WebSocket upgrade detection to only intercept /ws/script-execution
- Pass all other WebSocket upgrades (including HMR) to Next.js handler
- Ensure _next routes and static assets are properly handled by Next.js
- Fixes 400 errors for Next.js HMR WebSocket connections
- Fixes 403 errors for static assets by ensuring proper routing

* Fix WebSocket upgrade handling to properly route Next.js HMR

- Create WebSocketServer with noServer: true to avoid auto-attaching
- Manually handle upgrade events to route /ws/script-execution to our WebSocketServer
- Route all other WebSocket upgrades (including Next.js HMR) to Next.js handler
- This ensures Next.js HMR WebSocket connections are properly handled
- Fixes 400 errors for /_next/webpack-hmr WebSocket connections

* Revert WebSocket handling to simpler approach

- Go back to attaching WebSocketServer directly with path option
- Remove manual upgrade event handling that was causing errors
- The path option should filter to only /ws/script-execution
- Next.js should handle its own HMR WebSocket upgrades naturally

* Fix WebSocket upgrade handling to preserve Next.js HMR handlers

- Save existing upgrade listeners before adding our own
- Call existing listeners for non-matching paths to allow Next.js HMR
- Only handle /ws/script-execution ourselves
- This ensures Next.js can handle its own WebSocket upgrades for HMR

* Fix random page reloads during normal app usage

- Memoize startReconnectAttempts with useCallback to prevent recreation on every render
- Fix useEffect dependency arrays to include memoized function
- Add stricter guards checking refs before starting reconnect attempts
- Ensure reconnect logic only runs when actually updating (not during normal usage)
- Add early return in fallback useEffect to prevent false triggers
- Add ref guards in ResyncButton to prevent multiple simultaneous sync operations
- Only reload after sync if it was user-initiated

* Fix critical bug: prevent reloads from stale updateLogsData.isComplete

- Add isUpdating guard before processing updateLogsData.isComplete
- Reset shouldSubscribe when update completes or fails
- Prevent stale isComplete data from triggering reloads during normal usage

* Add update confirmation modal with changelog display

- Add UpdateConfirmationModal component that shows changelog before update
- Modify getVersionStatus to include release body (changelog) in response
- Update VersionDisplay to show confirmation modal instead of starting update directly
- Users must review changelog and click 'Proceed with Update' to start update
- Ensures users see potential breaking changes before updating
2025-11-26 10:21:14 +01:00
Michel Roegl-Brunner
2a9921a4e1 Merge pull request #348 from community-scripts/fix/update_script
fix: Detect script changes from remote repository to allow Script updates
2025-11-26 08:43:27 +01:00
Michel Roegl-Brunner
50f657ba00 Update Node.js version to 24.x in workflow 2025-11-26 08:33:50 +01:00
Michel Roegl-Brunner
5d5eba72de fix: detect script changes from remote repository
- Add refetchOnMount and staleTime: 0 to compareScriptContent query to bypass React Query cache
- Add visible refresh button in script detail modal to manually check for updates
- Improve comparison error handling and logging for better debugging
- Display error messages in UI when comparison fails
- Ensure comparison always checks remote repository when modal opens
2025-11-26 08:32:13 +01:00
Michel Roegl-Brunner
577b96518e package-lock.json 2025-11-26 08:19:39 +01:00
Michel Roegl-Brunner
c6c27271d6 Merge pull request #342 from community-scripts/node24_securityfix 2025-11-24 21:34:06 +01:00
ProxmoxVE Developer
72c0246d8c chore(deps): upgrade to Next.js 16, Vitest 4, and Node.js 24
BREAKING CHANGES:
- Upgrade Next.js from 15.1.6 to 16.0.4
- Use Webpack instead of Turbopack for compatibility with server-side modules
- Upgrade Node.js requirement to >=24.0.0

FEATURES:
- Upgrade Vitest to 4.0.13 with improved testing capabilities
- Update all vitest-related packages (@vitest/ui, @vitest/coverage-v8)
- Upgrade react-syntax-highlighter to 16.1.0
- Update node-cron to 4.2.1
- Update lucide-react to 0.554.0

SECURITY:
- Resolve glob command injection vulnerability (CVE) via v10.5.0
- Fix all npm audit vulnerabilities (0 vulnerabilities found)
- Update prisma/client to 6.19.0
- Update all dependencies to latest secure versions

FIXES:
- Configure next.config.js for webpack with proper server-side module handling
- Update tsconfig.json for Next.js 16 compatibility (jsx: react-jsx)
- Add engines field to require Node.js >=24.0.0
- Remove deprecated webpack config in favor of Next.js 16 compatibility
2025-11-24 21:27:38 +01:00
github-actions[bot]
06d4786e0a chore: add VERSION v0.4.13 (#341)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-24 20:08:21 +00:00
CanbiZ
bc31896586 core: remove uv cache clean | remove log rotation (#340)
* core: remove uv cache clean

* Initialize functions on core.func source

Added function initialization call when sourcing core.func
2025-11-24 20:52:31 +01:00
github-actions[bot]
213a606fc0 chore: add VERSION v0.4.12 (#336)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-20 17:59:41 +00:00
101 changed files with 24475 additions and 9730 deletions

View File

@@ -18,7 +18,12 @@ ALLOWED_SCRIPT_PATHS="scripts/"
WEBSOCKET_PORT="3001"
# User settings
# Optional tokens for private repos: GITHUB_TOKEN (GitHub), GITLAB_TOKEN (GitLab),
# BITBUCKET_APP_PASSWORD or BITBUCKET_TOKEN (Bitbucket). REPO_URL and added repos
# can be GitHub, GitLab, Bitbucket, or custom Git servers.
GITHUB_TOKEN=
GITLAB_TOKEN=
BITBUCKET_APP_PASSWORD=
SAVE_FILTER=false
FILTERS=
AUTH_USERNAME=

View File

@@ -4,7 +4,7 @@
## 🔗 Related PR / Issue
Link: #
Fixes: #
## ✅ Prerequisites (**X** in brackets)

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
node-version: [22.x]
node-version: [24.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:

3
.gitignore vendored
View File

@@ -16,6 +16,9 @@
db.sqlite
data/settings.db
# prisma generated client
/prisma/generated/
# ssh keys (sensitive)
data/ssh-keys/

View File

@@ -100,7 +100,7 @@ apt install -y nodejs
```bash
# Clone the repository
git clone https://github.com/community-scripts/ProxmoxVE-Local.git /opt/PVESciptslocal
cd PVESciptslocal
cd /opt/PVESciptslocal
# Install dependencies and build
npm install

View File

@@ -1 +1 @@
0.4.12
0.5.5

View File

@@ -1,15 +1,23 @@
import { FlatCompat } from "@eslint/eslintrc";
import eslintPluginNext from "@next/eslint-plugin-next";
import tseslint from "typescript-eslint";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks";
export default tseslint.config(
{
ignores: [".next"],
ignores: [".next", "next-env.d.ts", "postcss.config.js", "prettier.config.js"],
},
{
plugins: {
"@next/next": eslintPluginNext,
"react": reactPlugin,
"react-hooks": reactHooksPlugin,
},
rules: {
...eslintPluginNext.configs.recommended.rules,
...eslintPluginNext.configs["core-web-vitals"].rules,
},
},
...compat.extends("next/core-web-vitals"),
{
files: ["**/*.ts", "**/*.tsx"],
extends: [

View File

@@ -18,31 +18,25 @@ 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.*',
],
// Allow cross-origin requests from local network in dev mode
// Note: In Next.js 16, we disable this check entirely for dev
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
],
},
];
},
turbopack: {
// Disable Turbopack and use Webpack instead for compatibility
// This is necessary for server-side code that uses child_process
},
webpack: (config, { dev, isServer }) => {
if (dev && !isServer) {
config.watchOptions = {
@@ -50,15 +44,18 @@ const config = {
aggregateTimeout: 300,
};
}
// Handle server-side modules
if (isServer) {
config.externals = config.externals || [];
if (!config.externals.includes('child_process')) {
config.externals.push('child_process');
}
}
return config;
},
// Ignore ESLint errors during build (they can be fixed separately)
eslint: {
ignoreDuringBuilds: true,
},
// Ignore TypeScript errors during build (they can be fixed separately)
// TypeScript errors will fail the build
typescript: {
ignoreBuildErrors: true,
ignoreBuildErrors: false,
},
};

4546
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,20 @@
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"check": "next lint && tsc --noEmit",
"dev": "next dev",
"dev:server": "node server.js",
"dev:next": "next dev",
"build": "prisma generate && next build --webpack",
"check": "eslint . && tsc --noEmit",
"dev": "next dev --webpack",
"dev:server": "node --import tsx server.js",
"dev:next": "next dev --webpack",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
"lint:fix": "next lint --fix",
"generate": "prisma generate",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"migrate": "prisma migrate dev",
"preview": "next build && next start",
"start": "node server.js",
"postinstall": "prisma generate",
"start": "node --import tsx server.js",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
@@ -22,77 +25,83 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "^6.18.0",
"@prisma/adapter-better-sqlite3": "^7.2.0",
"@prisma/client": "^7.2.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8",
"@radix-ui/react-slot": "^1.2.4",
"@t3-oss/env-nextjs": "^0.13.10",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"@tanstack/react-query": "^5.90.18",
"@trpc/client": "^11.8.1",
"@trpc/react-query": "^11.8.1",
"@trpc/server": "^11.8.1",
"@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",
"axios": "^1.7.9",
"bcryptjs": "^3.0.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cron-validator": "^1.2.0",
"cron-validator": "^1.4.0",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.553.0",
"next": "^15.1.6",
"node-cron": "^3.0.3",
"node-pty": "^1.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.562.0",
"next": "^16.1.3",
"node-cron": "^4.2.1",
"node-pty": "^1.1.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"react-syntax-highlighter": "^16.1.0",
"refractor": "^5.0.0",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"strip-ansi": "^7.1.2",
"superjson": "^2.2.3",
"tailwind-merge": "^3.3.1",
"ws": "^8.18.3",
"zod": "^4.1.12"
"superjson": "^2.2.6",
"tailwind-merge": "^3.4.0",
"ws": "^8.19.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.8",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.1",
"@types/node": "^24.10.9",
"@types/node-cron": "^3.0.11",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.39.1",
"eslint-config-next": "^15.1.6",
"jsdom": "^27.2.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1",
"prisma": "^6.19.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.8.2",
"typescript-eslint": "^8.46.2",
"vitest": "^3.2.4"
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.17",
"@vitest/ui": "^4.0.17",
"baseline-browser-mapping": "^2.9.15",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.3",
"jsdom": "^27.4.0",
"postcss": "^8.5.6",
"prettier": "^3.8.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"prisma": "^7.2.0",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.0",
"vitest": "^4.0.17"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"packageManager": "npm@10.9.3",
"engines": {
"node": ">=24.0.0"
},
"overrides": {
"prismjs": "^1.30.0"
}
}
}

20
prisma.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import 'dotenv/config'
import path from 'path'
import { defineConfig } from 'prisma/config'
// Resolve database path
const dbPath = process.env.DATABASE_URL ?? `file:${path.join(process.cwd(), 'data', 'pve-scripts.db')}`
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: {
url: dbPath,
},
// @ts-expect-error - Prisma 7 config types are incomplete
studio: {
adapter: async () => {
const { PrismaBetterSqlite3 } = await import('@prisma/adapter-better-sqlite3')
return new PrismaBetterSqlite3({ url: dbPath })
},
},
})

View File

@@ -1,10 +1,10 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client"
output = "./generated/prisma"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model InstalledScript {

View File

@@ -1,4 +1,4 @@
# Copyright (c) 2021-2025 community-scripts ORG
# Copyright (c) 2021-2026 community-scripts ORG
# Author: tteck (tteckster)
# Co-Author: MickLesk
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
@@ -6,33 +6,68 @@
if ! command -v curl >/dev/null 2>&1; then
apk update && apk add curl >/dev/null 2>&1
fi
source "$(dirname "${BASH_SOURCE[0]}")/core.func"
source "$(dirname "${BASH_SOURCE[0]}")/error-handler.func"
load_functions
catch_errors
# Get LXC IP address (must be called INSIDE container, after network is up)
get_lxc_ip
# This function enables IPv6 if it's not disabled and sets verbose mode
verb_ip6() {
set_std_mode # Set STD mode based on VERBOSE
if [ "$DISABLEIPV6" == "yes" ]; then
if [ "${IPV6_METHOD:-}" = "disable" ]; then
msg_info "Disabling IPv6 (this may affect some services)"
$STD sysctl -w net.ipv6.conf.all.disable_ipv6=1
echo "net.ipv6.conf.all.disable_ipv6 = 1" >>/etc/sysctl.conf
$STD sysctl -w net.ipv6.conf.default.disable_ipv6=1
$STD sysctl -w net.ipv6.conf.lo.disable_ipv6=1
mkdir -p /etc/sysctl.d
$STD tee /etc/sysctl.d/99-disable-ipv6.conf >/dev/null <<EOF
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
EOF
$STD rc-update add sysctl default
msg_ok "Disabled IPv6"
fi
}
# This function catches errors and handles them with the error handler function
catch_errors() {
set -Eeuo pipefail
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
set -Eeuo pipefail
trap 'error_handler $? $LINENO "$BASH_COMMAND"' ERR
trap on_exit EXIT
trap on_interrupt INT
trap on_terminate TERM
error_handler() {
local exit_code="$1"
local line_number="$2"
local command="$3"
if [[ "$exit_code" -eq 0 ]]; then
return 0
fi
printf "\e[?25h"
echo -e "\n${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}\n"
exit "$exit_code"
}
# This function handles errors
error_handler() {
on_exit() {
local exit_code="$?"
local line_number="$1"
local command="$2"
local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}"
echo -e "\n$error_message\n"
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile"
exit "$exit_code"
}
on_interrupt() {
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}"
exit 130
}
on_terminate() {
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}"
exit 143
}
# This function sets up the Container OS by generating the locale, setting the timezone, and checking the network connection
@@ -61,10 +96,10 @@ network_check() {
set +e
trap - ERR
if ping -c 1 -W 1 1.1.1.1 &>/dev/null || ping -c 1 -W 1 8.8.8.8 &>/dev/null || ping -c 1 -W 1 9.9.9.9 &>/dev/null; then
msg_ok "Internet Connected"
ipv4_status="${GN}✔${CL} IPv4"
else
msg_error "Internet NOT Connected"
read -r -p "Would you like to continue anyway? <y/N> " prompt
ipv4_status="${RD}✖${CL} IPv4"
read -r -p "Internet NOT connected. Continue anyway? <y/N> " prompt
if [[ "${prompt,,}" =~ ^(y|yes)$ ]]; then
echo -e "${INFO}${RD}Expect Issues Without Internet${CL}"
else
@@ -73,7 +108,11 @@ network_check() {
fi
fi
RESOLVEDIP=$(getent hosts github.com | awk '{ print $1 }')
if [[ -z "$RESOLVEDIP" ]]; then msg_error "DNS Lookup Failure"; else msg_ok "DNS Resolved github.com to ${BL}$RESOLVEDIP${CL}"; fi
if [[ -z "$RESOLVEDIP" ]]; then
msg_error "Internet: ${ipv4_status} DNS Failed"
else
msg_ok "Internet: ${ipv4_status} DNS: ${BL}${RESOLVEDIP}${CL}"
fi
set -e
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
@@ -82,29 +121,20 @@ network_check() {
update_os() {
msg_info "Updating Container OS"
$STD apk -U upgrade
#source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/tools.func)
source "$(dirname "${BASH_SOURCE[0]}")/tools.func"
msg_ok "Updated Container OS"
}
# This function modifies the message of the day (motd) and SSH settings
motd_ssh() {
echo "export TERM='xterm-256color'" >>/root/.bashrc
IP=$(ip -4 addr show eth0 | awk '/inet / {print $2}' | cut -d/ -f1 | head -n 1)
if [ -f "/etc/os-release" ]; then
OS_NAME=$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '"')
OS_VERSION=$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '"')
else
OS_NAME="Alpine Linux"
OS_VERSION="Unknown"
fi
PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
echo "echo -e \"\"" >"$PROFILE_FILE"
echo -e "echo -e \"${BOLD}${APPLICATION} LXC Container${CL}"\" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${GATEWAY}${YW} Provided by: ${GN}community-scripts ORG ${YW}| GitHub: ${GN}https://github.com/community-scripts/ProxmoxVE${CL}\"" >>"$PROFILE_FILE"
echo "echo \"\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${OS}${YW} OS: ${GN}${OS_NAME} - Version: ${OS_VERSION}${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${OS}${YW} OS: ${GN}\$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '\"') - Version: \$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '\"')${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${HOSTNAME}${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${INFO}${YW} IP Address: ${GN}\$(ip -4 addr show eth0 | awk '/inet / {print \$2}' | cut -d/ -f1 | head -n 1)${CL}\"" >>"$PROFILE_FILE"
@@ -154,10 +184,4 @@ EOF
echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${app}.sh)\"" >/usr/bin/update
chmod +x /usr/bin/update
if [[ -n "${SSH_AUTHORIZED_KEY}" ]]; then
mkdir -p /root/.ssh
echo "${SSH_AUTHORIZED_KEY}" >/root/.ssh/authorized_keys
chmod 700 /root/.ssh
chmod 600 /root/.ssh/authorized_keys
fi
}

View File

@@ -0,0 +1,188 @@
#!/bin/ash
# shellcheck shell=ash
# Copyright (c) 2021-2026 community-scripts ORG
# Author: MickLesk
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
if ! command -v curl >/dev/null 2>&1; then
apk update && apk add curl >/dev/null 2>&1
fi
source "$(dirname "${BASH_SOURCE[0]}")/core.func"
source "$(dirname "${BASH_SOURCE[0]}")/error-handler.func"
load_functions
catch_errors
# Get LXC IP address (must be called INSIDE container, after network is up)
get_lxc_ip
# This function enables IPv6 if it's not disabled and sets verbose mode
verb_ip6() {
set_std_mode # Set STD mode based on VERBOSE
if [ "${IPV6_METHOD:-}" = "disable" ]; then
msg_info "Disabling IPv6 (this may affect some services)"
$STD sysctl -w net.ipv6.conf.all.disable_ipv6=1
$STD sysctl -w net.ipv6.conf.default.disable_ipv6=1
$STD sysctl -w net.ipv6.conf.lo.disable_ipv6=1
mkdir -p /etc/sysctl.d
$STD tee /etc/sysctl.d/99-disable-ipv6.conf >/dev/null <<EOF
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
EOF
$STD rc-update add sysctl default
msg_ok "Disabled IPv6"
fi
}
set -Eeuo pipefail
trap 'error_handler $? $LINENO "$BASH_COMMAND"' ERR
trap on_exit EXIT
trap on_interrupt INT
trap on_terminate TERM
error_handler() {
local exit_code="$1"
local line_number="$2"
local command="$3"
if [[ "$exit_code" -eq 0 ]]; then
return 0
fi
printf "\e[?25h"
echo -e "\n${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}\n"
exit "$exit_code"
}
on_exit() {
local exit_code="$?"
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile"
exit "$exit_code"
}
on_interrupt() {
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}"
exit 130
}
on_terminate() {
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}"
exit 143
}
# This function sets up the Container OS by generating the locale, setting the timezone, and checking the network connection
setting_up_container() {
msg_info "Setting up Container OS"
while [ $i -gt 0 ]; do
if [ "$(ip addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d'/' -f1)" != "" ]; then
break
fi
echo 1>&2 -en "${CROSS}${RD} No Network! "
sleep $RETRY_EVERY
i=$((i - 1))
done
if [ "$(ip addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d'/' -f1)" = "" ]; then
echo 1>&2 -e "\n${CROSS}${RD} No Network After $RETRY_NUM Tries${CL}"
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
msg_ok "Set up Container OS"
msg_ok "Network Connected: ${BL}$(ip addr show | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1 | tail -n1)${CL}"
}
# This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected
network_check() {
set +e
trap - ERR
if ping -c 1 -W 1 1.1.1.1 &>/dev/null || ping -c 1 -W 1 8.8.8.8 &>/dev/null || ping -c 1 -W 1 9.9.9.9 &>/dev/null; then
ipv4_status="${GN}✔${CL} IPv4"
else
ipv4_status="${RD}✖${CL} IPv4"
read -r -p "Internet NOT connected. Continue anyway? <y/N> " prompt
if [[ "${prompt,,}" =~ ^(y|yes)$ ]]; then
echo -e "${INFO}${RD}Expect Issues Without Internet${CL}"
else
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
fi
RESOLVEDIP=$(getent hosts github.com | awk '{ print $1 }')
if [[ -z "$RESOLVEDIP" ]]; then
msg_error "Internet: ${ipv4_status} DNS Failed"
else
msg_ok "Internet: ${ipv4_status} DNS: ${BL}${RESOLVEDIP}${CL}"
fi
set -e
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
# This function updates the Container OS by running apt-get update and upgrade
update_os() {
msg_info "Updating Container OS"
$STD apk -U upgrade
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/tools.func)
msg_ok "Updated Container OS"
}
# This function modifies the message of the day (motd) and SSH settings
motd_ssh() {
echo "export TERM='xterm-256color'" >>/root/.bashrc
PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
echo "echo -e \"\"" >"$PROFILE_FILE"
echo -e "echo -e \"${BOLD}${APPLICATION} LXC Container${CL}"\" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${GATEWAY}${YW} Provided by: ${GN}community-scripts ORG ${YW}| GitHub: ${GN}https://github.com/community-scripts/ProxmoxVE${CL}\"" >>"$PROFILE_FILE"
echo "echo \"\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${OS}${YW} OS: ${GN}\$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '\"') - Version: \$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '\"')${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${HOSTNAME}${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${INFO}${YW} IP Address: ${GN}\$(ip -4 addr show eth0 | awk '/inet / {print \$2}' | cut -d/ -f1 | head -n 1)${CL}\"" >>"$PROFILE_FILE"
# Configure SSH if enabled
if [[ "${SSH_ROOT}" == "yes" ]]; then
# Enable sshd service
$STD rc-update add sshd
# Allow root login via SSH
sed -i "s/#PermitRootLogin prohibit-password/PermitRootLogin yes/g" /etc/ssh/sshd_config
# Start the sshd service
$STD /etc/init.d/sshd start
fi
}
# Validate Timezone for some LXC's
validate_tz() {
[[ -f "/usr/share/zoneinfo/$1" ]]
}
# This function customizes the container and enables passwordless login for the root user
customize() {
if [[ "$PASSWORD" == "" ]]; then
msg_info "Customizing Container"
passwd -d root >/dev/null 2>&1
# Ensure agetty is available
apk add --no-cache --force-broken-world util-linux >/dev/null 2>&1
# Create persistent autologin boot script
mkdir -p /etc/local.d
cat <<'EOF' >/etc/local.d/autologin.start
#!/bin/sh
sed -i 's|^tty1::respawn:.*|tty1::respawn:/sbin/agetty --autologin root --noclear tty1 38400 linux|' /etc/inittab
kill -HUP 1
EOF
touch /root/.hushlogin
chmod +x /etc/local.d/autologin.start
rc-update add local >/dev/null 2>&1
# Apply autologin immediately for current session
/etc/local.d/autologin.start
msg_ok "Customized Container"
fi
echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${app}.sh)\"" >/usr/bin/update
chmod +x /usr/bin/update
}

View File

@@ -1,7 +1,154 @@
# Copyright (c) 2021-2025 community-scripts ORG
# Copyright (c) 2021-2026 community-scripts ORG
# Author: michelroegl-brunner
# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE
# ==============================================================================
# API.FUNC - TELEMETRY & DIAGNOSTICS API
# ==============================================================================
#
# Provides functions for sending anonymous telemetry data to Community-Scripts
# API for analytics and diagnostics purposes.
#
# Features:
# - Container/VM creation statistics
# - Installation success/failure tracking
# - Error code mapping and reporting
# - Privacy-respecting anonymous telemetry
#
# Usage:
# source <(curl -fsSL .../api.func)
# post_to_api # Report container creation
# post_update_to_api # Report installation status
#
# Privacy:
# - Only anonymous statistics (no personal data)
# - User can opt-out via diagnostics settings
# - Random UUID for session tracking only
#
# ==============================================================================
# ==============================================================================
# SECTION 1: ERROR CODE DESCRIPTIONS
# ==============================================================================
# ------------------------------------------------------------------------------
# explain_exit_code()
#
# - Maps numeric exit codes to human-readable error descriptions
# - Supports:
# * Generic/Shell errors (1, 2, 126, 127, 128, 130, 137, 139, 143)
# * Package manager errors (APT, DPKG: 100, 101, 255)
# * Node.js/npm errors (243-249, 254)
# * Python/pip/uv errors (210-212)
# * PostgreSQL errors (231-234)
# * MySQL/MariaDB errors (241-244)
# * MongoDB errors (251-254)
# * Proxmox custom codes (200-231)
# - Returns description string for given exit code
# - Shared function with error_handler.func for consistency
# ------------------------------------------------------------------------------
explain_exit_code() {
local code="$1"
case "$code" in
# --- Generic / Shell ---
1) echo "General error / Operation not permitted" ;;
2) echo "Misuse of shell builtins (e.g. syntax error)" ;;
126) echo "Command invoked cannot execute (permission problem?)" ;;
127) echo "Command not found" ;;
128) echo "Invalid argument to exit" ;;
130) echo "Terminated by Ctrl+C (SIGINT)" ;;
137) echo "Killed (SIGKILL / Out of memory?)" ;;
139) echo "Segmentation fault (core dumped)" ;;
143) echo "Terminated (SIGTERM)" ;;
# --- Package manager / APT / DPKG ---
100) echo "APT: Package manager error (broken packages / dependency problems)" ;;
101) echo "APT: Configuration error (bad sources.list, malformed config)" ;;
255) echo "DPKG: Fatal internal error" ;;
# --- Node.js / npm / pnpm / yarn ---
243) echo "Node.js: Out of memory (JavaScript heap out of memory)" ;;
245) echo "Node.js: Invalid command-line option" ;;
246) echo "Node.js: Internal JavaScript Parse Error" ;;
247) echo "Node.js: Fatal internal error" ;;
248) echo "Node.js: Invalid C++ addon / N-API failure" ;;
249) echo "Node.js: Inspector error" ;;
254) echo "npm/pnpm/yarn: Unknown fatal error" ;;
# --- Python / pip / uv ---
210) echo "Python: Virtualenv / uv environment missing or broken" ;;
211) echo "Python: Dependency resolution failed" ;;
212) echo "Python: Installation aborted (permissions or EXTERNALLY-MANAGED)" ;;
# --- PostgreSQL ---
231) echo "PostgreSQL: Connection failed (server not running / wrong socket)" ;;
232) echo "PostgreSQL: Authentication failed (bad user/password)" ;;
233) echo "PostgreSQL: Database does not exist" ;;
234) echo "PostgreSQL: Fatal error in query / syntax" ;;
# --- MySQL / MariaDB ---
241) echo "MySQL/MariaDB: Connection failed (server not running / wrong socket)" ;;
242) echo "MySQL/MariaDB: Authentication failed (bad user/password)" ;;
243) echo "MySQL/MariaDB: Database does not exist" ;;
244) echo "MySQL/MariaDB: Fatal error in query / syntax" ;;
# --- MongoDB ---
251) echo "MongoDB: Connection failed (server not running)" ;;
252) echo "MongoDB: Authentication failed (bad user/password)" ;;
253) echo "MongoDB: Database not found" ;;
254) echo "MongoDB: Fatal query error" ;;
# --- Proxmox Custom Codes ---
200) echo "Custom: Failed to create lock file" ;;
203) echo "Custom: Missing CTID variable" ;;
204) echo "Custom: Missing PCT_OSTYPE variable" ;;
205) echo "Custom: Invalid CTID (<100)" ;;
206) echo "Custom: CTID already in use (check 'pct list' and /etc/pve/lxc/)" ;;
207) echo "Custom: Password contains unescaped special characters (-, /, \\, *, etc.)" ;;
208) echo "Custom: Invalid configuration (DNS/MAC/Network format error)" ;;
209) echo "Custom: Container creation failed (check logs for pct create output)" ;;
210) echo "Custom: Cluster not quorate" ;;
211) echo "Custom: Timeout waiting for template lock (concurrent download in progress)" ;;
214) echo "Custom: Not enough storage space" ;;
215) echo "Custom: Container created but not listed (ghost state - check /etc/pve/lxc/)" ;;
216) echo "Custom: RootFS entry missing in config (incomplete creation)" ;;
217) echo "Custom: Storage does not support rootdir (check storage capabilities)" ;;
218) echo "Custom: Template file corrupted or incomplete download (size <1MB or invalid archive)" ;;
220) echo "Custom: Unable to resolve template path" ;;
221) echo "Custom: Template file exists but not readable (check file permissions)" ;;
222) echo "Custom: Template download failed after 3 attempts (network/storage issue)" ;;
223) echo "Custom: Template not available after download (storage sync issue)" ;;
225) echo "Custom: No template available for OS/Version (check 'pveam available')" ;;
231) echo "Custom: LXC stack upgrade/retry failed (outdated pve-container - check https://github.com/community-scripts/ProxmoxVE/discussions/8126)" ;;
# --- Default ---
*) echo "Unknown error" ;;
esac
}
# ==============================================================================
# SECTION 2: TELEMETRY FUNCTIONS
# ==============================================================================
# ------------------------------------------------------------------------------
# post_to_api()
#
# - Sends LXC container creation statistics to Community-Scripts API
# - Only executes if:
# * curl is available
# * DIAGNOSTICS=yes
# * RANDOM_UUID is set
# - Payload includes:
# * Container type, disk size, CPU cores, RAM
# * OS type and version
# * IPv6 disable status
# * Application name (NSAPP)
# * Installation method
# * PVE version
# * Status: "installing"
# * Random UUID for session tracking
# - Anonymous telemetry (no personal data)
# ------------------------------------------------------------------------------
post_to_api() {
if ! command -v curl &>/dev/null; then
@@ -30,7 +177,6 @@ post_to_api() {
"ram_size": $RAM_SIZE,
"os_type": "$var_os",
"os_version": "$var_version",
"disableip6": "",
"nsapp": "$NSAPP",
"method": "$METHOD(PVE-Local)",
"pve_version": "$pve_version",
@@ -39,14 +185,26 @@ post_to_api() {
}
EOF
)
if [[ "$DIAGNOSTICS" == "yes" ]]; then
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD") || true
fi
}
# ------------------------------------------------------------------------------
# post_to_api_vm()
#
# - Sends VM creation statistics to Community-Scripts API
# - Similar to post_to_api() but for virtual machines (not containers)
# - Reads DIAGNOSTICS from /usr/local/community-scripts/diagnostics file
# - Payload differences:
# * ct_type=2 (VM instead of LXC)
# * type="vm"
# * Disk size without 'G' suffix (parsed from DISK_SIZE variable)
# - Only executes if DIAGNOSTICS=yes and RANDOM_UUID is set
# ------------------------------------------------------------------------------
post_to_api_vm() {
if [[ ! -f /usr/local/community-scripts/diagnostics ]]; then
@@ -81,7 +239,6 @@ post_to_api_vm() {
"ram_size": $RAM_SIZE,
"os_type": "$var_os",
"os_version": "$var_version",
"disableip6": "",
"nsapp": "$NSAPP",
"method": "$METHOD(PVE-Local)",
"pve_version": "$pve_version",
@@ -90,7 +247,6 @@ post_to_api_vm() {
}
EOF
)
if [[ "$DIAGNOSTICS" == "yes" ]]; then
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
-H "Content-Type: application/json" \
@@ -98,19 +254,54 @@ EOF
fi
}
POST_UPDATE_DONE=false
# ------------------------------------------------------------------------------
# post_update_to_api()
#
# - Reports installation completion status to API
# - Prevents duplicate submissions via POST_UPDATE_DONE flag
# - Arguments:
# * $1: status ("success" or "failed")
# * $2: exit_code (default: 1 for failed, 0 for success)
# - Payload includes:
# * Final status (success/failed)
# * Error description via get_error_description()
# * Random UUID for session correlation
# - Only executes once per session
# - Silently returns if:
# * curl not available
# * Already reported (POST_UPDATE_DONE=true)
# * DIAGNOSTICS=no
# ------------------------------------------------------------------------------
post_update_to_api() {
if ! command -v curl &>/dev/null; then
return
fi
# Initialize flag if not set (prevents 'unbound variable' error with set -u)
POST_UPDATE_DONE=${POST_UPDATE_DONE:-false}
if [ "$POST_UPDATE_DONE" = true ]; then
return 0
fi
exit_code=${2:-1}
local API_URL="http://api.community-scripts.org/upload/updatestatus"
local status="${1:-failed}"
local error="${2:-No error message}"
if [[ "$status" == "failed" ]]; then
local exit_code="${2:-1}"
elif [[ "$status" == "success" ]]; then
local exit_code="${2:-0}"
fi
if [[ -z "$exit_code" ]]; then
exit_code=1
fi
error=$(explain_exit_code "$exit_code")
if [ -z "$error" ]; then
error="Unknown error"
fi
JSON_PAYLOAD=$(
cat <<EOF
@@ -121,7 +312,6 @@ post_update_to_api() {
}
EOF
)
if [[ "$DIAGNOSTICS" == "yes" ]]; then
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
-H "Content-Type: application/json" \

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,505 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2026 community-scripts ORG
# Author: community-scripts ORG
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/branch/main/LICENSE
# Revision: 1
# ==============================================================================
# CLOUD-INIT.FUNC - VM CLOUD-INIT CONFIGURATION LIBRARY
# ==============================================================================
#
# Universal helper library for Cloud-Init configuration in Proxmox VMs.
# Provides functions for:
#
# - Native Proxmox Cloud-Init setup (user, password, network, SSH keys)
# - Interactive configuration dialogs (whiptail)
# - IP address retrieval via qemu-guest-agent
# - Cloud-Init status monitoring and waiting
#
# Usage:
# source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/cloud-init.func)
# setup_cloud_init "$VMID" "$STORAGE" "$HN" "yes"
#
# Compatible with: Debian, Ubuntu, and all Cloud-Init enabled distributions
# ==============================================================================
# ==============================================================================
# SECTION 1: CONFIGURATION DEFAULTS
# ==============================================================================
# These can be overridden before sourcing this library
CLOUDINIT_DEFAULT_USER="${CLOUDINIT_DEFAULT_USER:-root}"
CLOUDINIT_DNS_SERVERS="${CLOUDINIT_DNS_SERVERS:-1.1.1.1 8.8.8.8}"
CLOUDINIT_SEARCH_DOMAIN="${CLOUDINIT_SEARCH_DOMAIN:-local}"
CLOUDINIT_SSH_KEYS="${CLOUDINIT_SSH_KEYS:-/root/.ssh/authorized_keys}"
# ==============================================================================
# SECTION 2: HELPER FUNCTIONS
# ==============================================================================
# ------------------------------------------------------------------------------
# _ci_msg - Internal message helper with fallback
# ------------------------------------------------------------------------------
function _ci_msg_info() { msg_info "$1" 2>/dev/null || echo "[INFO] $1"; }
function _ci_msg_ok() { msg_ok "$1" 2>/dev/null || echo "[OK] $1"; }
function _ci_msg_warn() { msg_warn "$1" 2>/dev/null || echo "[WARN] $1"; }
function _ci_msg_error() { msg_error "$1" 2>/dev/null || echo "[ERROR] $1"; }
# ------------------------------------------------------------------------------
# validate_ip_cidr - Validate IP address in CIDR format
# Usage: validate_ip_cidr "192.168.1.100/24" && echo "Valid"
# Returns: 0 if valid, 1 if invalid
# ------------------------------------------------------------------------------
function validate_ip_cidr() {
local ip_cidr="$1"
# Match: 0-255.0-255.0-255.0-255/0-32
if [[ "$ip_cidr" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]]; then
# Validate each octet is 0-255
local ip="${ip_cidr%/*}"
IFS='.' read -ra octets <<<"$ip"
for octet in "${octets[@]}"; do
((octet > 255)) && return 1
done
return 0
fi
return 1
}
# ------------------------------------------------------------------------------
# validate_ip - Validate plain IP address (no CIDR)
# Usage: validate_ip "192.168.1.1" && echo "Valid"
# ------------------------------------------------------------------------------
function validate_ip() {
local ip="$1"
if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
IFS='.' read -ra octets <<<"$ip"
for octet in "${octets[@]}"; do
((octet > 255)) && return 1
done
return 0
fi
return 1
}
# ==============================================================================
# SECTION 3: MAIN CLOUD-INIT FUNCTIONS
# ==============================================================================
# ------------------------------------------------------------------------------
# setup_cloud_init - Configures Proxmox Native Cloud-Init
# ------------------------------------------------------------------------------
# Parameters:
# $1 - VMID (required)
# $2 - Storage name (required)
# $3 - Hostname (optional, default: vm-<vmid>)
# $4 - Enable Cloud-Init (yes/no, default: no)
# $5 - User (optional, default: root)
# $6 - Network mode (dhcp/static, default: dhcp)
# $7 - Static IP (optional, format: 192.168.1.100/24)
# $8 - Gateway (optional)
# $9 - Nameservers (optional, default: 1.1.1.1 8.8.8.8)
#
# Returns: 0 on success, 1 on failure
# Exports: CLOUDINIT_USER, CLOUDINIT_PASSWORD, CLOUDINIT_CRED_FILE
# ==============================================================================
function setup_cloud_init() {
local vmid="$1"
local storage="$2"
local hostname="${3:-vm-${vmid}}"
local enable="${4:-no}"
local ciuser="${5:-$CLOUDINIT_DEFAULT_USER}"
local network_mode="${6:-dhcp}"
local static_ip="${7:-}"
local gateway="${8:-}"
local nameservers="${9:-$CLOUDINIT_DNS_SERVERS}"
# Skip if not enabled
if [ "$enable" != "yes" ]; then
return 0
fi
# Validate static IP if provided
if [ "$network_mode" = "static" ]; then
if [ -n "$static_ip" ] && ! validate_ip_cidr "$static_ip"; then
_ci_msg_error "Invalid static IP format: $static_ip (expected: x.x.x.x/xx)"
return 1
fi
if [ -n "$gateway" ] && ! validate_ip "$gateway"; then
_ci_msg_error "Invalid gateway IP format: $gateway"
return 1
fi
fi
_ci_msg_info "Configuring Cloud-Init"
# Create Cloud-Init drive (try ide2 first, then scsi1 as fallback)
if ! qm set "$vmid" --ide2 "${storage}:cloudinit" >/dev/null 2>&1; then
qm set "$vmid" --scsi1 "${storage}:cloudinit" >/dev/null 2>&1
fi
# Set user
qm set "$vmid" --ciuser "$ciuser" >/dev/null
# Generate and set secure random password
local cipassword=$(openssl rand -base64 16)
qm set "$vmid" --cipassword "$cipassword" >/dev/null
# Add SSH keys if available
if [ -f "$CLOUDINIT_SSH_KEYS" ]; then
qm set "$vmid" --sshkeys "$CLOUDINIT_SSH_KEYS" >/dev/null 2>&1 || true
fi
# Configure network
if [ "$network_mode" = "static" ] && [ -n "$static_ip" ] && [ -n "$gateway" ]; then
qm set "$vmid" --ipconfig0 "ip=${static_ip},gw=${gateway}" >/dev/null
else
qm set "$vmid" --ipconfig0 "ip=dhcp" >/dev/null
fi
# Set DNS servers
qm set "$vmid" --nameserver "$nameservers" >/dev/null
# Set search domain
qm set "$vmid" --searchdomain "$CLOUDINIT_SEARCH_DOMAIN" >/dev/null
# Enable package upgrades on first boot (if supported by Proxmox version)
qm set "$vmid" --ciupgrade 1 >/dev/null 2>&1 || true
# Save credentials to file (with restrictive permissions)
local cred_file="/tmp/${hostname}-${vmid}-cloud-init-credentials.txt"
umask 077
cat >"$cred_file" <<EOF
╔══════════════════════════════════════════════════════════════════╗
║ ⚠️ SECURITY WARNING: DELETE THIS FILE AFTER NOTING CREDENTIALS ║
╚══════════════════════════════════════════════════════════════════╝
Cloud-Init Credentials
────────────────────────────────────────
VM ID: ${vmid}
Hostname: ${hostname}
Created: $(date)
Username: ${ciuser}
Password: ${cipassword}
Network: ${network_mode}$([ "$network_mode" = "static" ] && echo " (IP: ${static_ip}, GW: ${gateway})" || echo " (DHCP)")
DNS: ${nameservers}
────────────────────────────────────────
SSH Access (if keys configured):
ssh ${ciuser}@<vm-ip>
Proxmox UI Configuration:
VM ${vmid} > Cloud-Init > Edit
- User, Password, SSH Keys
- Network (IP Config)
- DNS, Search Domain
────────────────────────────────────────
🗑️ To delete this file:
rm -f ${cred_file}
────────────────────────────────────────
EOF
chmod 600 "$cred_file"
_ci_msg_ok "Cloud-Init configured (User: ${ciuser})"
# Export for use in calling script (DO NOT display password here - will be shown in summary)
export CLOUDINIT_USER="$ciuser"
export CLOUDINIT_PASSWORD="$cipassword"
export CLOUDINIT_CRED_FILE="$cred_file"
return 0
}
# ==============================================================================
# SECTION 4: INTERACTIVE CONFIGURATION
# ==============================================================================
# ------------------------------------------------------------------------------
# configure_cloud_init_interactive - Whiptail dialog for Cloud-Init setup
# ------------------------------------------------------------------------------
# Prompts user for Cloud-Init configuration choices
# Returns configuration via exported variables:
# - CLOUDINIT_ENABLE (yes/no)
# - CLOUDINIT_USER
# - CLOUDINIT_NETWORK_MODE (dhcp/static)
# - CLOUDINIT_IP (if static)
# - CLOUDINIT_GW (if static)
# - CLOUDINIT_DNS
# ------------------------------------------------------------------------------
function configure_cloud_init_interactive() {
local default_user="${1:-root}"
# Check if whiptail is available
if ! command -v whiptail >/dev/null 2>&1; then
echo "Warning: whiptail not available, skipping interactive configuration"
export CLOUDINIT_ENABLE="no"
return 1
fi
# Ask if user wants to enable Cloud-Init
if ! (whiptail --backtitle "Proxmox VE Helper Scripts" --title "CLOUD-INIT" \
--yesno "Enable Cloud-Init for VM configuration?\n\nCloud-Init allows automatic configuration of:\n• User accounts and passwords\n• SSH keys\n• Network settings (DHCP/Static)\n• DNS configuration\n\nYou can also configure these settings later in Proxmox UI." 16 68); then
export CLOUDINIT_ENABLE="no"
return 0
fi
export CLOUDINIT_ENABLE="yes"
# Username
if CLOUDINIT_USER=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox \
"Cloud-Init Username" 8 58 "$default_user" --title "USERNAME" 3>&1 1>&2 2>&3); then
export CLOUDINIT_USER="${CLOUDINIT_USER:-$default_user}"
else
export CLOUDINIT_USER="$default_user"
fi
# Network configuration
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "NETWORK MODE" \
--yesno "Use DHCP for network configuration?\n\nSelect 'No' for static IP configuration." 10 58); then
export CLOUDINIT_NETWORK_MODE="dhcp"
else
export CLOUDINIT_NETWORK_MODE="static"
# Static IP with validation
while true; do
if CLOUDINIT_IP=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox \
"Static IP Address (CIDR format)\nExample: 192.168.1.100/24" 9 58 "" --title "IP ADDRESS" 3>&1 1>&2 2>&3); then
if validate_ip_cidr "$CLOUDINIT_IP"; then
export CLOUDINIT_IP
break
else
whiptail --backtitle "Proxmox VE Helper Scripts" --title "INVALID IP" \
--msgbox "Invalid IP format: $CLOUDINIT_IP\n\nPlease use CIDR format: x.x.x.x/xx\nExample: 192.168.1.100/24" 10 50
fi
else
_ci_msg_warn "Static IP required, falling back to DHCP"
export CLOUDINIT_NETWORK_MODE="dhcp"
break
fi
done
# Gateway with validation
if [ "$CLOUDINIT_NETWORK_MODE" = "static" ]; then
while true; do
if CLOUDINIT_GW=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox \
"Gateway IP Address\nExample: 192.168.1.1" 8 58 "" --title "GATEWAY" 3>&1 1>&2 2>&3); then
if validate_ip "$CLOUDINIT_GW"; then
export CLOUDINIT_GW
break
else
whiptail --backtitle "Proxmox VE Helper Scripts" --title "INVALID GATEWAY" \
--msgbox "Invalid gateway format: $CLOUDINIT_GW\n\nPlease use format: x.x.x.x\nExample: 192.168.1.1" 10 50
fi
else
_ci_msg_warn "Gateway required, falling back to DHCP"
export CLOUDINIT_NETWORK_MODE="dhcp"
break
fi
done
fi
fi
# DNS Servers
if CLOUDINIT_DNS=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox \
"DNS Servers (space-separated)" 8 58 "1.1.1.1 8.8.8.8" --title "DNS SERVERS" 3>&1 1>&2 2>&3); then
export CLOUDINIT_DNS="${CLOUDINIT_DNS:-1.1.1.1 8.8.8.8}"
else
export CLOUDINIT_DNS="1.1.1.1 8.8.8.8"
fi
return 0
}
# ==============================================================================
# SECTION 5: UTILITY FUNCTIONS
# ==============================================================================
# ------------------------------------------------------------------------------
# display_cloud_init_info - Show Cloud-Init summary after setup
# ------------------------------------------------------------------------------
function display_cloud_init_info() {
local vmid="$1"
local hostname="${2:-}"
if [ -n "$CLOUDINIT_CRED_FILE" ] && [ -f "$CLOUDINIT_CRED_FILE" ]; then
if [ -n "${INFO:-}" ]; then
echo -e "\n${INFO}${BOLD:-}${GN:-} Cloud-Init Configuration:${CL:-}"
echo -e "${TAB:- }${DGN:-}User: ${BGN:-}${CLOUDINIT_USER:-root}${CL:-}"
echo -e "${TAB:- }${DGN:-}Password: ${BGN:-}${CLOUDINIT_PASSWORD}${CL:-}"
echo -e "${TAB:- }${DGN:-}Credentials: ${BL:-}${CLOUDINIT_CRED_FILE}${CL:-}"
echo -e "${TAB:- }${RD:-}⚠️ Delete credentials file after noting password!${CL:-}"
echo -e "${TAB:- }${YW:-}💡 Configure in Proxmox UI: VM ${vmid} > Cloud-Init${CL:-}"
else
echo ""
echo "[INFO] Cloud-Init Configuration:"
echo " User: ${CLOUDINIT_USER:-root}"
echo " Password: ${CLOUDINIT_PASSWORD}"
echo " Credentials: ${CLOUDINIT_CRED_FILE}"
echo " ⚠️ Delete credentials file after noting password!"
echo " Configure in Proxmox UI: VM ${vmid} > Cloud-Init"
fi
fi
}
# ------------------------------------------------------------------------------
# cleanup_cloud_init_credentials - Remove credentials file
# ------------------------------------------------------------------------------
# Usage: cleanup_cloud_init_credentials
# Call this after user has noted/saved the credentials
# ------------------------------------------------------------------------------
function cleanup_cloud_init_credentials() {
if [ -n "$CLOUDINIT_CRED_FILE" ] && [ -f "$CLOUDINIT_CRED_FILE" ]; then
rm -f "$CLOUDINIT_CRED_FILE"
_ci_msg_ok "Credentials file removed: $CLOUDINIT_CRED_FILE"
unset CLOUDINIT_CRED_FILE
return 0
fi
return 1
}
# ------------------------------------------------------------------------------
# has_cloud_init - Check if VM has Cloud-Init configured
# ------------------------------------------------------------------------------
function has_cloud_init() {
local vmid="$1"
qm config "$vmid" 2>/dev/null | grep -qE "(ide2|scsi1):.*cloudinit"
}
# ------------------------------------------------------------------------------
# regenerate_cloud_init - Regenerate Cloud-Init configuration
# ------------------------------------------------------------------------------
function regenerate_cloud_init() {
local vmid="$1"
if has_cloud_init "$vmid"; then
_ci_msg_info "Regenerating Cloud-Init configuration"
qm cloudinit update "$vmid" >/dev/null 2>&1 || true
_ci_msg_ok "Cloud-Init configuration regenerated"
return 0
else
_ci_msg_warn "VM $vmid does not have Cloud-Init configured"
return 1
fi
}
# ------------------------------------------------------------------------------
# get_vm_ip - Get VM IP address via qemu-guest-agent
# ------------------------------------------------------------------------------
function get_vm_ip() {
local vmid="$1"
local timeout="${2:-30}"
local elapsed=0
while [ $elapsed -lt $timeout ]; do
local vm_ip=$(qm guest cmd "$vmid" network-get-interfaces 2>/dev/null |
jq -r '.[] | select(.name != "lo") | ."ip-addresses"[]? | select(."ip-address-type" == "ipv4") | ."ip-address"' 2>/dev/null | head -1)
if [ -n "$vm_ip" ]; then
echo "$vm_ip"
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
return 1
}
# ------------------------------------------------------------------------------
# wait_for_cloud_init - Wait for Cloud-Init to complete (requires SSH access)
# ------------------------------------------------------------------------------
function wait_for_cloud_init() {
local vmid="$1"
local timeout="${2:-300}"
local vm_ip="${3:-}"
# Get IP if not provided
if [ -z "$vm_ip" ]; then
vm_ip=$(get_vm_ip "$vmid" 60)
fi
if [ -z "$vm_ip" ]; then
_ci_msg_warn "Unable to determine VM IP address"
return 1
fi
_ci_msg_info "Waiting for Cloud-Init to complete on ${vm_ip}"
local elapsed=0
while [ $elapsed -lt $timeout ]; do
if timeout 10 ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"${CLOUDINIT_USER:-root}@${vm_ip}" "cloud-init status --wait" 2>/dev/null; then
_ci_msg_ok "Cloud-Init completed successfully"
return 0
fi
sleep 10
elapsed=$((elapsed + 10))
done
_ci_msg_warn "Cloud-Init did not complete within ${timeout}s"
return 1
}
# ==============================================================================
# SECTION 6: EXPORTS
# ==============================================================================
# Export all functions for use in other scripts
export -f setup_cloud_init 2>/dev/null || true
export -f configure_cloud_init_interactive 2>/dev/null || true
export -f display_cloud_init_info 2>/dev/null || true
export -f cleanup_cloud_init_credentials 2>/dev/null || true
export -f has_cloud_init 2>/dev/null || true
export -f regenerate_cloud_init 2>/dev/null || true
export -f get_vm_ip 2>/dev/null || true
export -f wait_for_cloud_init 2>/dev/null || true
export -f validate_ip_cidr 2>/dev/null || true
export -f validate_ip 2>/dev/null || true
# ==============================================================================
# SECTION 7: EXAMPLES & DOCUMENTATION
# ==============================================================================
: <<'EXAMPLES'
# Example 1: Simple DHCP setup (most common)
setup_cloud_init "$VMID" "$STORAGE" "$HN" "yes"
# Example 2: Static IP setup
setup_cloud_init "$VMID" "$STORAGE" "myserver" "yes" "root" "static" "192.168.1.100/24" "192.168.1.1"
# Example 3: Interactive configuration in advanced_settings()
configure_cloud_init_interactive "admin"
if [ "$CLOUDINIT_ENABLE" = "yes" ]; then
setup_cloud_init "$VMID" "$STORAGE" "$HN" "yes" "$CLOUDINIT_USER" \
"$CLOUDINIT_NETWORK_MODE" "$CLOUDINIT_IP" "$CLOUDINIT_GW" "$CLOUDINIT_DNS"
fi
# Example 4: Display info after VM creation
display_cloud_init_info "$VMID" "$HN"
# Example 5: Check if VM has Cloud-Init
if has_cloud_init "$VMID"; then
echo "Cloud-Init is configured"
fi
# Example 6: Wait for Cloud-Init to complete after VM start
if [ "$START_VM" = "yes" ]; then
qm start "$VMID"
sleep 30
wait_for_cloud_init "$VMID" 300
fi
# Example 7: Cleanup credentials file after user has noted password
display_cloud_init_info "$VMID" "$HN"
read -p "Have you saved the credentials? (y/N): " -r
[[ $REPLY =~ ^[Yy]$ ]] && cleanup_cloud_init_credentials
# Example 8: Validate IP before using
if validate_ip_cidr "192.168.1.100/24"; then
echo "Valid IP/CIDR"
fi
EXAMPLES

View File

@@ -1,699 +0,0 @@
config_file() {
CONFIG_FILE="/opt/community-scripts/.settings"
if [[ -f "/opt/community-scripts/${NSAPP}.conf" ]]; then
CONFIG_FILE="/opt/community-scripts/${NSAPP}.conf"
fi
if CONFIG_FILE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set absolute path to config file" 8 58 "$CONFIG_FILE" --title "CONFIG FILE" 3>&1 1>&2 2>&3); then
if [[ ! -f "$CONFIG_FILE" ]]; then
echo -e "${CROSS}${RD}Config file not found, exiting script!.${CL}"
exit
else
echo -e "${INFO}${BOLD}${DGN}Using config File: ${BGN}$CONFIG_FILE${CL}"
source "$CONFIG_FILE"
fi
fi
if [[ -n "${CT_ID-}" ]]; then
if [[ "$CT_ID" =~ ^([0-9]{3,4})-([0-9]{3,4})$ ]]; then
MIN_ID=${BASH_REMATCH[1]}
MAX_ID=${BASH_REMATCH[2]}
if ((MIN_ID >= MAX_ID)); then
msg_error "Invalid Container ID range. The first number must be smaller than the second number, was ${CT_ID}"
exit
fi
LIST_OF_IDS=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -oP '"vmid":\s*\K\d+') || true
if [[ -n "$LIST_OF_IDS" ]]; then
for ((ID = MIN_ID; ID <= MAX_ID; ID++)); do
if ! grep -q "^$ID$" <<<"$LIST_OF_IDS"; then
CT_ID=$ID
break
fi
done
fi
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
elif [[ "$CT_ID" =~ ^[0-9]+$ ]]; then
LIST_OF_IDS=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -oP '"vmid":\s*\K\d+') || true
if [[ -n "$LIST_OF_IDS" ]]; then
if ! grep -q "^$CT_ID$" <<<"$LIST_OF_IDS"; then
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
else
msg_error "Container ID $CT_ID already exists"
exit
fi
else
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
fi
else
msg_error "Invalid Container ID format. Needs to be 0000-9999 or 0-9999, was ${CT_ID}"
exit
fi
else
if CT_ID=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Container ID" 8 58 "$NEXTID" --title "CONTAINER ID" 3>&1 1>&2 2>&3); then
if [ -z "$CT_ID" ]; then
CT_ID="$NEXTID"
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
else
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
fi
else
exit_script
fi
fi
if [[ -n "${CT_TYPE-}" ]]; then
if [[ "$CT_TYPE" -eq 0 ]]; then
CT_TYPE_DESC="Privileged"
elif [[ "$CT_TYPE" -eq 1 ]]; then
CT_TYPE_DESC="Unprivileged"
else
msg_error "Unknown setting for CT_TYPE, should be 1 or 0, was ${CT_TYPE}"
exit
fi
echo -e "${CONTAINERTYPE}${BOLD}${DGN}Container Type: ${BGN}$CT_TYPE_DESC${CL}"
else
if CT_TYPE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "CONTAINER TYPE" --radiolist "Choose Type" 10 58 2 \
"1" "Unprivileged" ON \
"0" "Privileged" OFF \
3>&1 1>&2 2>&3); then
if [ -n "$CT_TYPE" ]; then
CT_TYPE_DESC="Unprivileged"
if [ "$CT_TYPE" -eq 0 ]; then
CT_TYPE_DESC="Privileged"
fi
echo -e "${CONTAINERTYPE}${BOLD}${DGN}Container Type: ${BGN}$CT_TYPE_DESC${CL}"
fi
else
exit_script
fi
fi
if [[ -n "${PW-}" ]]; then
if [[ "$PW" == "none" ]]; then
PW=""
else
if [[ "$PW" == *" "* ]]; then
msg_error "Password cannot be empty"
exit
elif [[ ${#PW} -lt 5 ]]; then
msg_error "Password must be at least 5 characters long"
exit
else
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}********${CL}"
fi
PW="-password $PW"
fi
else
while true; do
if PW1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --passwordbox "\nSet Root Password (needed for root ssh access)" 9 58 --title "PASSWORD (leave blank for automatic login)" 3>&1 1>&2 2>&3); then
if [[ -n "$PW1" ]]; then
if [[ "$PW1" == *" "* ]]; then
whiptail --msgbox "Password cannot contain spaces. Please try again." 8 58
elif [ ${#PW1} -lt 5 ]; then
whiptail --msgbox "Password must be at least 5 characters long. Please try again." 8 58
else
if PW2=$(whiptail --backtitle "Proxmox VE Helper Scripts" --passwordbox "\nVerify Root Password" 9 58 --title "PASSWORD VERIFICATION" 3>&1 1>&2 2>&3); then
if [[ "$PW1" == "$PW2" ]]; then
PW="-password $PW1"
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}********${CL}"
break
else
whiptail --msgbox "Passwords do not match. Please try again." 8 58
fi
else
exit_script
fi
fi
else
PW1="Automatic Login"
PW=""
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}$PW1${CL}"
break
fi
else
exit_script
fi
done
fi
if [[ -n "${HN-}" ]]; then
echo -e "${HOSTNAME}${BOLD}${DGN}Hostname: ${BGN}$HN${CL}"
else
if CT_NAME=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Hostname" 8 58 "$NSAPP" --title "HOSTNAME" 3>&1 1>&2 2>&3); then
if [ -z "$CT_NAME" ]; then
HN="$NSAPP"
else
HN=$(echo "${CT_NAME,,}" | tr -d ' ')
fi
echo -e "${HOSTNAME}${BOLD}${DGN}Hostname: ${BGN}$HN${CL}"
else
exit_script
fi
fi
if [[ -n "${DISK_SIZE-}" ]]; then
if [[ "$DISK_SIZE" =~ ^-?[0-9]+$ ]]; then
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}"
else
msg_error "DISK_SIZE must be an integer, was ${DISK_SIZE}"
exit
fi
else
if DISK_SIZE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Disk Size in GB" 8 58 "$var_disk" --title "DISK SIZE" 3>&1 1>&2 2>&3); then
if [ -z "$DISK_SIZE" ]; then
DISK_SIZE="$var_disk"
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}"
else
if ! [[ $DISK_SIZE =~ $INTEGER ]]; then
echo -e "{INFO}${HOLD}${RD} DISK SIZE MUST BE AN INTEGER NUMBER!${CL}"
advanced_settings
fi
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}"
fi
else
exit_script
fi
fi
if [[ -n "${CORE_COUNT-}" ]]; then
if [[ "$CORE_COUNT" =~ ^-?[0-9]+$ ]]; then
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}${CORE_COUNT}${CL}"
else
msg_error "CORE_COUNT must be an integer, was ${CORE_COUNT}"
exit
fi
else
if CORE_COUNT=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Allocate CPU Cores" 8 58 "$var_cpu" --title "CORE COUNT" 3>&1 1>&2 2>&3); then
if [ -z "$CORE_COUNT" ]; then
CORE_COUNT="$var_cpu"
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}$CORE_COUNT${CL}"
else
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}$CORE_COUNT${CL}"
fi
else
exit_script
fi
fi
if [[ -n "${RAM_SIZE-}" ]]; then
if [[ "$RAM_SIZE" =~ ^-?[0-9]+$ ]]; then
echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}"
else
msg_error "RAM_SIZE must be an integer, was ${RAM_SIZE}"
exit
fi
else
if RAM_SIZE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Allocate RAM in MiB" 8 58 "$var_ram" --title "RAM" 3>&1 1>&2 2>&3); then
if [ -z "$RAM_SIZE" ]; then
RAM_SIZE="$var_ram"
echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}"
else
echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}"
fi
else
exit_script
fi
fi
IFACE_FILEPATH_LIST="/etc/network/interfaces"$'\n'$(find "/etc/network/interfaces.d/" -type f)
BRIDGES=""
OLD_IFS=$IFS
IFS=$'\n'
for iface_filepath in ${IFACE_FILEPATH_LIST}; do
iface_indexes_tmpfile=$(mktemp -q -u '.iface-XXXX')
(grep -Pn '^\s*iface' "${iface_filepath}" | cut -d':' -f1 && wc -l "${iface_filepath}" | cut -d' ' -f1) | awk 'FNR==1 {line=$0; next} {print line":"$0-1; line=$0}' >"${iface_indexes_tmpfile}" || true
if [ -f "${iface_indexes_tmpfile}" ]; then
while read -r pair; do
start=$(echo "${pair}" | cut -d':' -f1)
end=$(echo "${pair}" | cut -d':' -f2)
if awk "NR >= ${start} && NR <= ${end}" "${iface_filepath}" | grep -qP '^\s*(bridge[-_](ports|stp|fd|vlan-aware|vids)|ovs_type\s+OVSBridge)\b'; then
iface_name=$(sed "${start}q;d" "${iface_filepath}" | awk '{print $2}')
BRIDGES="${iface_name}"$'\n'"${BRIDGES}"
fi
done <"${iface_indexes_tmpfile}"
rm -f "${iface_indexes_tmpfile}"
fi
done
IFS=$OLD_IFS
BRIDGES=$(echo "$BRIDGES" | grep -v '^\s*$' | sort | uniq)
if [[ -n "${BRG-}" ]]; then
if echo "$BRIDGES" | grep -q "${BRG}"; then
echo -e "${BRIDGE}${BOLD}${DGN}Bridge: ${BGN}$BRG${CL}"
else
msg_error "Bridge '${BRG}' does not exist in /etc/network/interfaces or /etc/network/interfaces.d/sdn"
exit
fi
else
BRG=$(whiptail --backtitle "Proxmox VE Helper Scripts" --menu "Select network bridge:" 15 40 6 $(echo "$BRIDGES" | awk '{print $0, "Bridge"}') 3>&1 1>&2 2>&3)
if [ -z "$BRG" ]; then
exit_script
else
echo -e "${BRIDGE}${BOLD}${DGN}Bridge: ${BGN}$BRG${CL}"
fi
fi
local ip_cidr_regex='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/([0-9]{1,2})$'
local ip_regex='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$'
if [[ -n ${NET-} ]]; then
if [ "$NET" == "dhcp" ]; then
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}DHCP${CL}"
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}Default${CL}"
GATE=""
elif [[ "$NET" =~ $ip_cidr_regex ]]; then
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}$NET${CL}"
if [[ -n "$GATE" ]]; then
[[ "$GATE" =~ ",gw=" ]] && GATE="${GATE##,gw=}"
if [[ "$GATE" =~ $ip_regex ]]; then
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE${CL}"
GATE=",gw=$GATE"
else
msg_error "Invalid IP Address format for Gateway. Needs to be 0.0.0.0, was ${GATE}"
exit
fi
else
while true; do
GATE1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Enter gateway IP address" 8 58 --title "Gateway IP" 3>&1 1>&2 2>&3)
if [ -z "$GATE1" ]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Gateway IP address cannot be empty" 8 58
elif [[ ! "$GATE1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Invalid IP address format" 8 58
else
GATE=",gw=$GATE1"
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE1${CL}"
break
fi
done
fi
elif [[ "$NET" == *-* ]]; then
IFS="-" read -r ip_start ip_end <<<"$NET"
if [[ ! "$ip_start" =~ $ip_cidr_regex ]] || [[ ! "$ip_end" =~ $ip_cidr_regex ]]; then
msg_error "Invalid IP range format, was $NET should be 0.0.0.0/0-0.0.0.0/0"
exit 1
fi
ip1="${ip_start%%/*}"
ip2="${ip_end%%/*}"
cidr="${ip_start##*/}"
ip_to_int() {
local IFS=.
read -r i1 i2 i3 i4 <<<"$1"
echo $(((i1 << 24) + (i2 << 16) + (i3 << 8) + i4))
}
int_to_ip() {
local ip=$1
echo "$(((ip >> 24) & 0xFF)).$(((ip >> 16) & 0xFF)).$(((ip >> 8) & 0xFF)).$((ip & 0xFF))"
}
start_int=$(ip_to_int "$ip1")
end_int=$(ip_to_int "$ip2")
for ((ip_int = start_int; ip_int <= end_int; ip_int++)); do
ip=$(int_to_ip $ip_int)
msg_info "Checking IP: $ip"
if ! ping -c 2 -W 1 "$ip" >/dev/null 2>&1; then
NET="$ip/$cidr"
msg_ok "Using free IP Address: ${BGN}$NET${CL}"
sleep 3
break
fi
done
if [[ "$NET" == *-* ]]; then
msg_error "No free IP found in range"
exit 1
fi
if [ -n "$GATE" ]; then
if [[ "$GATE" =~ $ip_regex ]]; then
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE${CL}"
GATE=",gw=$GATE"
else
msg_error "Invalid IP Address format for Gateway. Needs to be 0.0.0.0, was ${GATE}"
exit
fi
else
while true; do
GATE1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Enter gateway IP address" 8 58 --title "Gateway IP" 3>&1 1>&2 2>&3)
if [ -z "$GATE1" ]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Gateway IP address cannot be empty" 8 58
elif [[ ! "$GATE1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Invalid IP address format" 8 58
else
GATE=",gw=$GATE1"
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE1${CL}"
break
fi
done
fi
else
msg_error "Invalid IP Address format. Needs to be 0.0.0.0/0 or a range like 10.0.0.1/24-10.0.0.10/24, was ${NET}"
exit
fi
else
while true; do
NET=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a Static IPv4 CIDR Address (/24)" 8 58 dhcp --title "IP ADDRESS" 3>&1 1>&2 2>&3)
exit_status=$?
if [ $exit_status -eq 0 ]; then
if [ "$NET" = "dhcp" ]; then
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}$NET${CL}"
break
else
if [[ "$NET" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]]; then
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}$NET${CL}"
break
else
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "$NET is an invalid IPv4 CIDR address. Please enter a valid IPv4 CIDR address or 'dhcp'" 8 58
fi
fi
else
exit_script
fi
done
if [ "$NET" != "dhcp" ]; then
while true; do
GATE1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Enter gateway IP address" 8 58 --title "Gateway IP" 3>&1 1>&2 2>&3)
if [ -z "$GATE1" ]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Gateway IP address cannot be empty" 8 58
elif [[ ! "$GATE1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Invalid IP address format" 8 58
else
GATE=",gw=$GATE1"
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE1${CL}"
break
fi
done
else
GATE=""
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}Default${CL}"
fi
fi
if [ "$var_os" == "alpine" ]; then
APT_CACHER=""
APT_CACHER_IP=""
else
if [[ -n "${APT_CACHER_IP-}" ]]; then
if [[ ! $APT_CACHER_IP == "none" ]]; then
APT_CACHER="yes"
echo -e "${NETWORK}${BOLD}${DGN}APT-CACHER IP Address: ${BGN}$APT_CACHER_IP${CL}"
else
APT_CACHER=""
echo -e "${NETWORK}${BOLD}${DGN}APT-Cacher IP Address: ${BGN}No${CL}"
fi
else
if APT_CACHER_IP=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set APT-Cacher IP (leave blank for none)" 8 58 --title "APT-Cacher IP" 3>&1 1>&2 2>&3); then
APT_CACHER="${APT_CACHER_IP:+yes}"
echo -e "${NETWORK}${BOLD}${DGN}APT-Cacher IP Address: ${BGN}${APT_CACHER_IP:-Default}${CL}"
if [[ -n $APT_CACHER_IP ]]; then
APT_CACHER_IP="none"
fi
else
exit_script
fi
fi
fi
if [[ -n "${MTU-}" ]]; then
if [[ "$MTU" =~ ^-?[0-9]+$ ]]; then
echo -e "${DEFAULT}${BOLD}${DGN}Interface MTU Size: ${BGN}$MTU${CL}"
MTU=",mtu=$MTU"
else
msg_error "MTU must be an integer, was ${MTU}"
exit
fi
else
if MTU1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Interface MTU Size (leave blank for default [The MTU of your selected vmbr, default is 1500])" 8 58 --title "MTU SIZE" 3>&1 1>&2 2>&3); then
if [ -z "$MTU1" ]; then
MTU1="Default"
MTU=""
else
MTU=",mtu=$MTU1"
fi
echo -e "${DEFAULT}${BOLD}${DGN}Interface MTU Size: ${BGN}$MTU1${CL}"
else
exit_script
fi
fi
if [[ "$IPV6_METHOD" == "static" ]]; then
if [[ -n "$IPV6STATIC" ]]; then
IP6=",ip6=${IPV6STATIC}"
echo -e "${NETWORK}${BOLD}${DGN}IPv6 Address: ${BGN}${IPV6STATIC}${CL}"
else
msg_error "IPV6_METHOD is set to static but IPV6STATIC is empty"
exit
fi
elif [[ "$IPV6_METHOD" == "auto" ]]; then
IP6=",ip6=auto"
echo -e "${NETWORK}${BOLD}${DGN}IPv6 Address: ${BGN}auto${CL}"
else
IP6=""
echo -e "${NETWORK}${BOLD}${DGN}IPv6 Address: ${BGN}none${CL}"
fi
if [[ -n "${SD-}" ]]; then
if [[ "$SD" == "none" ]]; then
SD=""
echo -e "${SEARCH}${BOLD}${DGN}DNS Search Domain: ${BGN}Host${CL}"
else
# Strip prefix if present for config file storage
local SD_VALUE="$SD"
[[ "$SD" =~ ^-searchdomain= ]] && SD_VALUE="${SD#-searchdomain=}"
echo -e "${SEARCH}${BOLD}${DGN}DNS Search Domain: ${BGN}$SD_VALUE${CL}"
SD="-searchdomain=$SD_VALUE"
fi
else
if SD=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a DNS Search Domain (leave blank for HOST)" 8 58 --title "DNS Search Domain" 3>&1 1>&2 2>&3); then
if [ -z "$SD" ]; then
SX=Host
SD=""
else
SX=$SD
SD="-searchdomain=$SD"
fi
echo -e "${SEARCH}${BOLD}${DGN}DNS Search Domain: ${BGN}$SX${CL}"
else
exit_script
fi
fi
if [[ -n "${NS-}" ]]; then
if [[ $NS == "none" ]]; then
NS=""
echo -e "${NETWORK}${BOLD}${DGN}DNS Server IP Address: ${BGN}Host${CL}"
else
# Strip prefix if present for config file storage
local NS_VALUE="$NS"
[[ "$NS" =~ ^-nameserver= ]] && NS_VALUE="${NS#-nameserver=}"
if [[ "$NS_VALUE" =~ $ip_regex ]]; then
echo -e "${NETWORK}${BOLD}${DGN}DNS Server IP Address: ${BGN}$NS_VALUE${CL}"
NS="-nameserver=$NS_VALUE"
else
msg_error "Invalid IP Address format for DNS Server. Needs to be 0.0.0.0, was ${NS_VALUE}"
exit
fi
fi
else
if NX=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a DNS Server IP (leave blank for HOST)" 8 58 --title "DNS SERVER IP" 3>&1 1>&2 2>&3); then
if [ -z "$NX" ]; then
NX=Host
NS=""
else
NS="-nameserver=$NX"
fi
echo -e "${NETWORK}${BOLD}${DGN}DNS Server IP Address: ${BGN}$NX${CL}"
else
exit_script
fi
fi
if [[ -n "${MAC-}" ]]; then
if [[ "$MAC" == "none" ]]; then
MAC=""
echo -e "${MACADDRESS}${BOLD}${DGN}MAC Address: ${BGN}Host${CL}"
else
# Strip prefix if present for config file storage
local MAC_VALUE="$MAC"
[[ "$MAC" =~ ^,hwaddr= ]] && MAC_VALUE="${MAC#,hwaddr=}"
if [[ "$MAC_VALUE" =~ ^([A-Fa-f0-9]{2}:){5}[A-Fa-f0-9]{2}$ ]]; then
echo -e "${MACADDRESS}${BOLD}${DGN}MAC Address: ${BGN}$MAC_VALUE${CL}"
MAC=",hwaddr=$MAC_VALUE"
else
msg_error "MAC Address must be in the format xx:xx:xx:xx:xx:xx, was ${MAC_VALUE}"
exit
fi
fi
else
if MAC1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a MAC Address(leave blank for generated MAC)" 8 58 --title "MAC ADDRESS" 3>&1 1>&2 2>&3); then
if [ -z "$MAC1" ]; then
MAC1="Default"
MAC=""
else
MAC=",hwaddr=$MAC1"
echo -e "${MACADDRESS}${BOLD}${DGN}MAC Address: ${BGN}$MAC1${CL}"
fi
else
exit_script
fi
fi
if [[ -n "${VLAN-}" ]]; then
if [[ "$VLAN" == "none" ]]; then
VLAN=""
echo -e "${VLANTAG}${BOLD}${DGN}Vlan: ${BGN}Host${CL}"
else
# Strip prefix if present for config file storage
local VLAN_VALUE="$VLAN"
[[ "$VLAN" =~ ^,tag= ]] && VLAN_VALUE="${VLAN#,tag=}"
if [[ "$VLAN_VALUE" =~ ^-?[0-9]+$ ]]; then
echo -e "${VLANTAG}${BOLD}${DGN}Vlan: ${BGN}$VLAN_VALUE${CL}"
VLAN=",tag=$VLAN_VALUE"
else
msg_error "VLAN must be an integer, was ${VLAN_VALUE}"
exit
fi
fi
else
if VLAN1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a Vlan(leave blank for no VLAN)" 8 58 --title "VLAN" 3>&1 1>&2 2>&3); then
if [ -z "$VLAN1" ]; then
VLAN1="Default"
VLAN=""
else
VLAN=",tag=$VLAN1"
fi
echo -e "${VLANTAG}${BOLD}${DGN}Vlan: ${BGN}$VLAN1${CL}"
else
exit_script
fi
fi
if [[ -n "${TAGS-}" ]]; then
if [[ "$TAGS" == *"DEFAULT"* ]]; then
TAGS="${TAGS//DEFAULT/}"
TAGS="${TAGS//;/}"
TAGS="$TAGS;${var_tags:-}"
echo -e "${NETWORK}${BOLD}${DGN}Tags: ${BGN}$TAGS${CL}"
fi
else
TAGS="community-scripts;"
if ADV_TAGS=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Custom Tags?[If you remove all, there will be no tags!]" 8 58 "${TAGS}" --title "Advanced Tags" 3>&1 1>&2 2>&3); then
if [ -n "${ADV_TAGS}" ]; then
ADV_TAGS=$(echo "$ADV_TAGS" | tr -d '[:space:]')
TAGS="${ADV_TAGS}"
else
TAGS=";"
fi
echo -e "${NETWORK}${BOLD}${DGN}Tags: ${BGN}$TAGS${CL}"
else
exit_script
fi
fi
if [[ -n "${SSH-}" ]]; then
if [[ "$SSH" == "yes" ]]; then
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
if [[ ! -z "$SSH_AUTHORIZED_KEY" ]]; then
echo -e "${ROOTSSH}${BOLD}${DGN}SSH Authorized Key: ${BGN}********************${CL}"
else
echo -e "${ROOTSSH}${BOLD}${DGN}SSH Authorized Key: ${BGN}None${CL}"
fi
elif [[ "$SSH" == "no" ]]; then
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
else
msg_error "SSH needs to be 'yes' or 'no', was ${SSH}"
exit
fi
else
SSH_AUTHORIZED_KEY="$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "SSH Authorized key for root (leave empty for none)" 8 58 --title "SSH Key" 3>&1 1>&2 2>&3)"
if [[ -z "${SSH_AUTHORIZED_KEY}" ]]; then
SSH_AUTHORIZED_KEY=""
fi
if [[ "$PW" == -password* || -n "$SSH_AUTHORIZED_KEY" ]]; then
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "SSH ACCESS" --yesno "Enable Root SSH Access?" 10 58); then
SSH="yes"
else
SSH="no"
fi
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
else
SSH="no"
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
fi
fi
if [[ -n "$ENABLE_FUSE" ]]; then
if [[ "$ENABLE_FUSE" == "yes" ]]; then
echo -e "${FUSE}${BOLD}${DGN}Enable FUSE: ${BGN}Yes${CL}"
elif [[ "$ENABLE_FUSE" == "no" ]]; then
echo -e "${FUSE}${BOLD}${DGN}Enable FUSE: ${BGN}No${CL}"
else
msg_error "Enable FUSE needs to be 'yes' or 'no', was ${ENABLE_FUSE}"
exit
fi
else
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "FUSE" --yesno "Enable FUSE?" 10 58); then
ENABLE_FUSE="yes"
else
ENABLE_FUSE="no"
fi
echo -e "${FUSE}${BOLD}${DGN}Enable FUSE: ${BGN}$ENABLE_FUSE${CL}"
fi
if [[ -n "$ENABLE_TUN" ]]; then
if [[ "$ENABLE_TUN" == "yes" ]]; then
echo -e "${FUSE}${BOLD}${DGN}Enable TUN: ${BGN}Yes${CL}"
elif [[ "$ENABLE_TUN" == "no" ]]; then
echo -e "${FUSE}${BOLD}${DGN}Enable TUN: ${BGN}No${CL}"
else
msg_error "Enable TUN needs to be 'yes' or 'no', was ${ENABLE_TUN}"
exit
fi
else
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "TUN" --yesno "Enable TUN?" 10 58); then
ENABLE_TUN="yes"
else
ENABLE_TUN="no"
fi
echo -e "${FUSE}${BOLD}${DGN}Enable TUN: ${BGN}$ENABLE_TUN${CL}"
fi
if [[ -n "${VERBOSE-}" ]]; then
if [[ "$VERBOSE" == "yes" ]]; then
echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}$VERBOSE${CL}"
elif [[ "$VERBOSE" == "no" ]]; then
echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}No${CL}"
else
msg_error "Verbose Mode needs to be 'yes' or 'no', was ${VERBOSE}"
exit
fi
else
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "VERBOSE MODE" --yesno "Enable Verbose Mode?" 10 58); then
VERBOSE="yes"
else
VERBOSE="no"
fi
echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}$VERBOSE${CL}"
fi
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "ADVANCED SETTINGS WITH CONFIG FILE COMPLETE" --yesno "Ready to create ${APP} LXC?" 10 58); then
echo -e "${CREATING}${BOLD}${RD}Creating a ${APP} LXC using the above settings${CL}"
else
clear
header_info
echo -e "${INFO}${HOLD} ${GN}Using Config File on node $PVEHOST_NAME${CL}"
config_file
fi
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,380 +0,0 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# Co-Author: MickLesk
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# This sets verbose mode if the global variable is set to "yes"
# if [ "$VERBOSE" == "yes" ]; then set -x; fi
source "$(dirname "$0")/core.func"
# This sets error handling options and defines the error_handler function to handle errors
set -Eeuo pipefail
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
trap on_exit EXIT
trap on_interrupt INT
trap on_terminate TERM
function on_exit() {
local exit_code="$?"
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile"
exit "$exit_code"
}
function error_handler() {
local exit_code="$?"
local line_number="$1"
local command="$2"
printf "\e[?25h"
echo -e "\n${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}\n"
exit "$exit_code"
}
function on_interrupt() {
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}"
exit 130
}
function on_terminate() {
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}"
exit 143
}
function exit_script() {
clear
printf "\e[?25h"
echo -e "\n${CROSS}${RD}User exited script${CL}\n"
kill 0
exit 1
}
function check_storage_support() {
local CONTENT="$1"
local -a VALID_STORAGES=()
while IFS= read -r line; do
local STORAGE_NAME
STORAGE_NAME=$(awk '{print $1}' <<<"$line")
[[ -z "$STORAGE_NAME" ]] && continue
VALID_STORAGES+=("$STORAGE_NAME")
done < <(pvesm status -content "$CONTENT" 2>/dev/null | awk 'NR>1')
[[ ${#VALID_STORAGES[@]} -gt 0 ]]
}
# This function selects a storage pool for a given content type (e.g., rootdir, vztmpl).
function select_storage() {
local CLASS=$1 CONTENT CONTENT_LABEL
case $CLASS in
container)
CONTENT='rootdir'
CONTENT_LABEL='Container'
;;
template)
CONTENT='vztmpl'
CONTENT_LABEL='Container template'
;;
iso)
CONTENT='iso'
CONTENT_LABEL='ISO image'
;;
images)
CONTENT='images'
CONTENT_LABEL='VM Disk image'
;;
backup)
CONTENT='backup'
CONTENT_LABEL='Backup'
;;
snippets)
CONTENT='snippets'
CONTENT_LABEL='Snippets'
;;
*)
msg_error "Invalid storage class '$CLASS'"
return 1
;;
esac
# Check for preset STORAGE variable
if [ "$CONTENT" = "rootdir" ] && [ -n "${STORAGE:-}" ]; then
if pvesm status -content "$CONTENT" | awk 'NR>1 {print $1}' | grep -qx "$STORAGE"; then
STORAGE_RESULT="$STORAGE"
msg_info "Using preset storage: $STORAGE_RESULT for $CONTENT_LABEL"
return 0
else
msg_error "Preset storage '$STORAGE' is not valid for content type '$CONTENT'."
return 2
fi
fi
local -A STORAGE_MAP
local -a MENU
local COL_WIDTH=0
while read -r TAG TYPE _ TOTAL USED FREE _; do
[[ -n "$TAG" && -n "$TYPE" ]] || continue
local STORAGE_NAME="$TAG"
local DISPLAY="${STORAGE_NAME} (${TYPE})"
local USED_FMT=$(numfmt --to=iec --from-unit=K --format %.1f <<<"$USED")
local FREE_FMT=$(numfmt --to=iec --from-unit=K --format %.1f <<<"$FREE")
local INFO="Free: ${FREE_FMT}B Used: ${USED_FMT}B"
STORAGE_MAP["$DISPLAY"]="$STORAGE_NAME"
MENU+=("$DISPLAY" "$INFO" "OFF")
((${#DISPLAY} > COL_WIDTH)) && COL_WIDTH=${#DISPLAY}
done < <(pvesm status -content "$CONTENT" | awk 'NR>1')
if [ ${#MENU[@]} -eq 0 ]; then
msg_error "No storage found for content type '$CONTENT'."
return 2
fi
if [ $((${#MENU[@]} / 3)) -eq 1 ]; then
STORAGE_RESULT="${STORAGE_MAP[${MENU[0]}]}"
STORAGE_INFO="${MENU[1]}"
return 0
fi
local WIDTH=$((COL_WIDTH + 42))
while true; do
local DISPLAY_SELECTED
DISPLAY_SELECTED=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
--title "Storage Pools" \
--radiolist "Which storage pool for ${CONTENT_LABEL,,}?\n(Spacebar to select)" \
16 "$WIDTH" 6 "${MENU[@]}" 3>&1 1>&2 2>&3)
# Cancel or ESC
[[ $? -ne 0 ]] && exit_script
# Strip trailing whitespace or newline (important for storages like "storage (dir)")
DISPLAY_SELECTED=$(sed 's/[[:space:]]*$//' <<<"$DISPLAY_SELECTED")
if [[ -z "$DISPLAY_SELECTED" || -z "${STORAGE_MAP[$DISPLAY_SELECTED]+_}" ]]; then
whiptail --msgbox "No valid storage selected. Please try again." 8 58
continue
fi
STORAGE_RESULT="${STORAGE_MAP[$DISPLAY_SELECTED]}"
for ((i = 0; i < ${#MENU[@]}; i += 3)); do
if [[ "${MENU[$i]}" == "$DISPLAY_SELECTED" ]]; then
STORAGE_INFO="${MENU[$i + 1]}"
break
fi
done
return 0
done
}
# Test if required variables are set
[[ "${CTID:-}" ]] || {
msg_error "You need to set 'CTID' variable."
exit 203
}
[[ "${PCT_OSTYPE:-}" ]] || {
msg_error "You need to set 'PCT_OSTYPE' variable."
exit 204
}
# Test if ID is valid
[ "$CTID" -ge "100" ] || {
msg_error "ID cannot be less than 100."
exit 205
}
# Test if ID is in use
if qm status "$CTID" &>/dev/null || pct status "$CTID" &>/dev/null; then
echo -e "ID '$CTID' is already in use."
unset CTID
msg_error "Cannot use ID that is already in use."
exit 206
fi
# This checks for the presence of valid Container Storage and Template Storage locations
msg_info "Validating storage"
if ! check_storage_support "rootdir"; then
msg_error "No valid storage found for 'rootdir' [Container]"
exit 1
fi
if ! check_storage_support "vztmpl"; then
msg_error "No valid storage found for 'vztmpl' [Template]"
exit 1
fi
#msg_info "Checking template storage"
while true; do
if select_storage template; then
TEMPLATE_STORAGE="$STORAGE_RESULT"
TEMPLATE_STORAGE_INFO="$STORAGE_INFO"
msg_ok "Storage ${BL}$TEMPLATE_STORAGE${CL} ($TEMPLATE_STORAGE_INFO) [Template]"
break
fi
done
while true; do
if select_storage container; then
CONTAINER_STORAGE="$STORAGE_RESULT"
CONTAINER_STORAGE_INFO="$STORAGE_INFO"
msg_ok "Storage ${BL}$CONTAINER_STORAGE${CL} ($CONTAINER_STORAGE_INFO) [Container]"
break
fi
done
# Check free space on selected container storage
STORAGE_FREE=$(pvesm status | awk -v s="$CONTAINER_STORAGE" '$1 == s { print $6 }')
REQUIRED_KB=$((${PCT_DISK_SIZE:-8} * 1024 * 1024))
if [ "$STORAGE_FREE" -lt "$REQUIRED_KB" ]; then
msg_error "Not enough space on '$CONTAINER_STORAGE'. Needed: ${PCT_DISK_SIZE:-8}G."
exit 214
fi
# Check Cluster Quorum if in Cluster
if [ -f /etc/pve/corosync.conf ]; then
msg_info "Checking cluster quorum"
if ! pvecm status | awk -F':' '/^Quorate/ { exit ($2 ~ /Yes/) ? 0 : 1 }'; then
msg_error "Cluster is not quorate. Start all nodes or configure quorum device (QDevice)."
exit 210
fi
msg_ok "Cluster is quorate"
fi
# Update LXC template list
TEMPLATE_SEARCH="${PCT_OSTYPE}-${PCT_OSVERSION:-}"
case "$PCT_OSTYPE" in
debian | ubuntu)
TEMPLATE_PATTERN="-standard_"
;;
alpine | fedora | rocky | centos)
TEMPLATE_PATTERN="-default_"
;;
*)
TEMPLATE_PATTERN=""
;;
esac
# 1. Check local templates first
msg_info "Searching for template '$TEMPLATE_SEARCH'"
mapfile -t TEMPLATES < <(
pveam list "$TEMPLATE_STORAGE" |
awk -v s="$TEMPLATE_SEARCH" -v p="$TEMPLATE_PATTERN" '$1 ~ s && $1 ~ p {print $1}' |
sed 's/.*\///' | sort -t - -k 2 -V
)
if [ ${#TEMPLATES[@]} -gt 0 ]; then
TEMPLATE_SOURCE="local"
else
msg_info "No local template found, checking online repository"
pveam update >/dev/null 2>&1
mapfile -t TEMPLATES < <(
pveam update >/dev/null 2>&1 &&
pveam available -section system |
sed -n "s/.*\($TEMPLATE_SEARCH.*$TEMPLATE_PATTERN.*\)/\1/p" |
sort -t - -k 2 -V
)
TEMPLATE_SOURCE="online"
fi
TEMPLATE="${TEMPLATES[-1]}"
TEMPLATE_PATH="$(pvesm path $TEMPLATE_STORAGE:vztmpl/$TEMPLATE 2>/dev/null ||
echo "/var/lib/vz/template/cache/$TEMPLATE")"
msg_ok "Template ${BL}$TEMPLATE${CL} [$TEMPLATE_SOURCE]"
# 4. Validate template (exists & not corrupted)
TEMPLATE_VALID=1
if [ ! -s "$TEMPLATE_PATH" ]; then
TEMPLATE_VALID=0
elif ! tar --use-compress-program=zstdcat -tf "$TEMPLATE_PATH" >/dev/null 2>&1; then
TEMPLATE_VALID=0
fi
if [ "$TEMPLATE_VALID" -eq 0 ]; then
msg_warn "Template $TEMPLATE is missing or corrupted. Re-downloading."
[[ -f "$TEMPLATE_PATH" ]] && rm -f "$TEMPLATE_PATH"
for attempt in {1..3}; do
msg_info "Attempt $attempt: Downloading LXC template..."
if pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null 2>&1; then
msg_ok "Template download successful."
break
fi
if [ $attempt -eq 3 ]; then
msg_error "Failed after 3 attempts. Please check network access or manually run:\n pveam download $TEMPLATE_STORAGE $TEMPLATE"
exit 208
fi
sleep $((attempt * 5))
done
fi
msg_info "Creating LXC Container"
# Check and fix subuid/subgid
grep -q "root:100000:65536" /etc/subuid || echo "root:100000:65536" >>/etc/subuid
grep -q "root:100000:65536" /etc/subgid || echo "root:100000:65536" >>/etc/subgid
# Combine all options
PCT_OPTIONS=(${PCT_OPTIONS[@]:-${DEFAULT_PCT_OPTIONS[@]}})
[[ " ${PCT_OPTIONS[@]} " =~ " -rootfs " ]] || PCT_OPTIONS+=(-rootfs "$CONTAINER_STORAGE:${PCT_DISK_SIZE:-8}")
# Secure creation of the LXC container with lock and template check
lockfile="/tmp/template.${TEMPLATE}.lock"
exec 9>"$lockfile" || {
msg_error "Failed to create lock file '$lockfile'."
exit 200
}
flock -w 60 9 || {
msg_error "Timeout while waiting for template lock"
exit 211
}
if ! pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" "${PCT_OPTIONS[@]}" &>/dev/null; then
msg_error "Container creation failed. Checking if template is corrupted or incomplete."
if [[ ! -s "$TEMPLATE_PATH" || "$(stat -c%s "$TEMPLATE_PATH")" -lt 1000000 ]]; then
msg_error "Template file too small or missing re-downloading."
rm -f "$TEMPLATE_PATH"
elif ! zstdcat "$TEMPLATE_PATH" | tar -tf - &>/dev/null; then
msg_error "Template appears to be corrupted re-downloading."
rm -f "$TEMPLATE_PATH"
else
msg_error "Template is valid, but container creation still failed."
exit 209
fi
# Retry download
for attempt in {1..3}; do
msg_info "Attempt $attempt: Re-downloading template..."
if timeout 120 pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null; then
msg_ok "Template re-download successful."
break
fi
if [ "$attempt" -eq 3 ]; then
msg_error "Three failed attempts. Aborting."
exit 208
fi
sleep $((attempt * 5))
done
sleep 1 # I/O-Sync-Delay
msg_ok "Re-downloaded LXC Template"
fi
if ! pct list | awk '{print $1}' | grep -qx "$CTID"; then
msg_error "Container ID $CTID not listed in 'pct list' unexpected failure."
exit 215
fi
if ! grep -q '^rootfs:' "/etc/pve/lxc/$CTID.conf"; then
msg_error "RootFS entry missing in container config storage not correctly assigned."
exit 216
fi
if grep -q '^hostname:' "/etc/pve/lxc/$CTID.conf"; then
CT_HOSTNAME=$(grep '^hostname:' "/etc/pve/lxc/$CTID.conf" | awk '{print $2}')
if [[ ! "$CT_HOSTNAME" =~ ^[a-z0-9-]+$ ]]; then
msg_warn "Hostname '$CT_HOSTNAME' contains invalid characters may cause issues with networking or DNS."
fi
fi
msg_ok "LXC Container ${BL}$CTID${CL} ${GN}was successfully created."

View File

@@ -0,0 +1,322 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# ERROR HANDLER - ERROR & SIGNAL MANAGEMENT
# ------------------------------------------------------------------------------
# Copyright (c) 2021-2026 community-scripts ORG
# Author: MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# ------------------------------------------------------------------------------
#
# Provides comprehensive error handling and signal management for all scripts.
# Includes:
# - Exit code explanations (shell, package managers, databases, custom codes)
# - Error handler with detailed logging
# - Signal handlers (EXIT, INT, TERM)
# - Initialization function for trap setup
#
# Usage:
# source <(curl -fsSL .../error_handler.func)
# catch_errors
#
# ------------------------------------------------------------------------------
# ==============================================================================
# SECTION 1: EXIT CODE EXPLANATIONS
# ==============================================================================
# ------------------------------------------------------------------------------
# explain_exit_code()
#
# - Maps numeric exit codes to human-readable error descriptions
# - Supports:
# * Generic/Shell errors (1, 2, 126, 127, 128, 130, 137, 139, 143)
# * Package manager errors (APT, DPKG: 100, 101, 255)
# * Node.js/npm errors (243-249, 254)
# * Python/pip/uv errors (210-212)
# * PostgreSQL errors (231-234)
# * MySQL/MariaDB errors (241-244)
# * MongoDB errors (251-254)
# * Proxmox custom codes (200-231)
# - Returns description string for given exit code
# ------------------------------------------------------------------------------
explain_exit_code() {
local code="$1"
case "$code" in
# --- Generic / Shell ---
1) echo "General error / Operation not permitted" ;;
2) echo "Misuse of shell builtins (e.g. syntax error)" ;;
126) echo "Command invoked cannot execute (permission problem?)" ;;
127) echo "Command not found" ;;
128) echo "Invalid argument to exit" ;;
130) echo "Terminated by Ctrl+C (SIGINT)" ;;
137) echo "Killed (SIGKILL / Out of memory?)" ;;
139) echo "Segmentation fault (core dumped)" ;;
143) echo "Terminated (SIGTERM)" ;;
# --- Package manager / APT / DPKG ---
100) echo "APT: Package manager error (broken packages / dependency problems)" ;;
101) echo "APT: Configuration error (bad sources.list, malformed config)" ;;
255) echo "DPKG: Fatal internal error" ;;
# --- Node.js / npm / pnpm / yarn ---
243) echo "Node.js: Out of memory (JavaScript heap out of memory)" ;;
245) echo "Node.js: Invalid command-line option" ;;
246) echo "Node.js: Internal JavaScript Parse Error" ;;
247) echo "Node.js: Fatal internal error" ;;
248) echo "Node.js: Invalid C++ addon / N-API failure" ;;
249) echo "Node.js: Inspector error" ;;
254) echo "npm/pnpm/yarn: Unknown fatal error" ;;
# --- Python / pip / uv ---
210) echo "Python: Virtualenv / uv environment missing or broken" ;;
211) echo "Python: Dependency resolution failed" ;;
212) echo "Python: Installation aborted (permissions or EXTERNALLY-MANAGED)" ;;
# --- PostgreSQL ---
231) echo "PostgreSQL: Connection failed (server not running / wrong socket)" ;;
232) echo "PostgreSQL: Authentication failed (bad user/password)" ;;
233) echo "PostgreSQL: Database does not exist" ;;
234) echo "PostgreSQL: Fatal error in query / syntax" ;;
# --- MySQL / MariaDB ---
241) echo "MySQL/MariaDB: Connection failed (server not running / wrong socket)" ;;
242) echo "MySQL/MariaDB: Authentication failed (bad user/password)" ;;
243) echo "MySQL/MariaDB: Database does not exist" ;;
244) echo "MySQL/MariaDB: Fatal error in query / syntax" ;;
# --- MongoDB ---
251) echo "MongoDB: Connection failed (server not running)" ;;
252) echo "MongoDB: Authentication failed (bad user/password)" ;;
253) echo "MongoDB: Database not found" ;;
254) echo "MongoDB: Fatal query error" ;;
# --- Proxmox Custom Codes ---
200) echo "Proxmox: Failed to create lock file" ;;
203) echo "Proxmox: Missing CTID variable" ;;
204) echo "Proxmox: Missing PCT_OSTYPE variable" ;;
205) echo "Proxmox: Invalid CTID (<100)" ;;
206) echo "Proxmox: CTID already in use" ;;
207) echo "Proxmox: Password contains unescaped special characters" ;;
208) echo "Proxmox: Invalid configuration (DNS/MAC/Network format)" ;;
209) echo "Proxmox: Container creation failed" ;;
210) echo "Proxmox: Cluster not quorate" ;;
211) echo "Proxmox: Timeout waiting for template lock" ;;
212) echo "Proxmox: Storage type 'iscsidirect' does not support containers (VMs only)" ;;
213) echo "Proxmox: Storage type does not support 'rootdir' content" ;;
214) echo "Proxmox: Not enough storage space" ;;
215) echo "Proxmox: Container created but not listed (ghost state)" ;;
216) echo "Proxmox: RootFS entry missing in config" ;;
217) echo "Proxmox: Storage not accessible" ;;
219) echo "Proxmox: CephFS does not support containers - use RBD" ;;
224) echo "Proxmox: PBS storage is for backups only" ;;
218) echo "Proxmox: Template file corrupted or incomplete" ;;
220) echo "Proxmox: Unable to resolve template path" ;;
221) echo "Proxmox: Template file not readable" ;;
222) echo "Proxmox: Template download failed" ;;
223) echo "Proxmox: Template not available after download" ;;
225) echo "Proxmox: No template available for OS/Version" ;;
231) echo "Proxmox: LXC stack upgrade failed" ;;
# --- Default ---
*) echo "Unknown error" ;;
esac
}
# ==============================================================================
# SECTION 2: ERROR HANDLERS
# ==============================================================================
# ------------------------------------------------------------------------------
# error_handler()
#
# - Main error handler triggered by ERR trap
# - Arguments: exit_code, command, line_number
# - Behavior:
# * Returns silently if exit_code is 0 (success)
# * Sources explain_exit_code() for detailed error description
# * Displays error message with:
# - Line number where error occurred
# - Exit code with explanation
# - Command that failed
# * Shows last 20 lines of SILENT_LOGFILE if available
# * Copies log to container /root for later inspection
# * Exits with original exit code
# ------------------------------------------------------------------------------
error_handler() {
local exit_code=${1:-$?}
local command=${2:-${BASH_COMMAND:-unknown}}
local line_number=${BASH_LINENO[0]:-unknown}
command="${command//\$STD/}"
if [[ "$exit_code" -eq 0 ]]; then
return 0
fi
local explanation
explanation="$(explain_exit_code "$exit_code")"
printf "\e[?25h"
# Use msg_error if available, fallback to echo
if declare -f msg_error >/dev/null 2>&1; then
msg_error "in line ${line_number}: exit code ${exit_code} (${explanation}): while executing command ${command}"
else
echo -e "\n${RD}[ERROR]${CL} in line ${RD}${line_number}${CL}: exit code ${RD}${exit_code}${CL} (${explanation}): while executing command ${YWB}${command}${CL}\n"
fi
if [[ -n "${DEBUG_LOGFILE:-}" ]]; then
{
echo "------ ERROR ------"
echo "Timestamp : $(date '+%Y-%m-%d %H:%M:%S')"
echo "Exit Code : $exit_code ($explanation)"
echo "Line : $line_number"
echo "Command : $command"
echo "-------------------"
} >>"$DEBUG_LOGFILE"
fi
# Get active log file (BUILD_LOG or INSTALL_LOG)
local active_log=""
if declare -f get_active_logfile >/dev/null 2>&1; then
active_log="$(get_active_logfile)"
elif [[ -n "${SILENT_LOGFILE:-}" ]]; then
active_log="$SILENT_LOGFILE"
fi
if [[ -n "$active_log" && -s "$active_log" ]]; then
echo "--- Last 20 lines of silent log ---"
tail -n 20 "$active_log"
echo "-----------------------------------"
# Detect context: Container (INSTALL_LOG set + /root exists) vs Host (BUILD_LOG)
if [[ -n "${INSTALL_LOG:-}" && -d /root ]]; then
# CONTAINER CONTEXT: Copy log and create flag file for host
local container_log="/root/.install-${SESSION_ID:-error}.log"
cp "$active_log" "$container_log" 2>/dev/null || true
# Create error flag file with exit code for host detection
echo "$exit_code" >"/root/.install-${SESSION_ID:-error}.failed" 2>/dev/null || true
if declare -f msg_custom >/dev/null 2>&1; then
msg_custom "📋" "${YW}" "Log saved to: ${container_log}"
else
echo -e "${YW}Log saved to:${CL} ${BL}${container_log}${CL}"
fi
else
# HOST CONTEXT: Show local log path and offer container cleanup
if declare -f msg_custom >/dev/null 2>&1; then
msg_custom "📋" "${YW}" "Full log: ${active_log}"
else
echo -e "${YW}Full log:${CL} ${BL}${active_log}${CL}"
fi
# Offer to remove container if it exists (build errors after container creation)
if [[ -n "${CTID:-}" ]] && command -v pct &>/dev/null && pct status "$CTID" &>/dev/null; then
echo ""
echo -en "${YW}Remove broken container ${CTID}? (Y/n) [auto-remove in 60s]: ${CL}"
if read -t 60 -r response; then
if [[ -z "$response" || "$response" =~ ^[Yy]$ ]]; then
echo -e "\n${YW}Removing container ${CTID}${CL}"
pct stop "$CTID" &>/dev/null || true
pct destroy "$CTID" &>/dev/null || true
echo -e "${GN}✔${CL} Container ${CTID} removed"
elif [[ "$response" =~ ^[Nn]$ ]]; then
echo -e "\n${YW}Container ${CTID} kept for debugging${CL}"
fi
else
# Timeout - auto-remove
echo -e "\n${YW}No response - auto-removing container${CL}"
pct stop "$CTID" &>/dev/null || true
pct destroy "$CTID" &>/dev/null || true
echo -e "${GN}✔${CL} Container ${CTID} removed"
fi
fi
fi
fi
exit "$exit_code"
}
# ==============================================================================
# SECTION 3: SIGNAL HANDLERS
# ==============================================================================
# ------------------------------------------------------------------------------
# on_exit()
#
# - EXIT trap handler
# - Cleans up lock files if lockfile variable is set
# - Exits with captured exit code
# - Always runs on script termination (success or failure)
# ------------------------------------------------------------------------------
on_exit() {
local exit_code=$?
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile"
exit "$exit_code"
}
# ------------------------------------------------------------------------------
# on_interrupt()
#
# - SIGINT (Ctrl+C) trap handler
# - Displays "Interrupted by user" message
# - Exits with code 130 (128 + SIGINT=2)
# ------------------------------------------------------------------------------
on_interrupt() {
if declare -f msg_error >/dev/null 2>&1; then
msg_error "Interrupted by user (SIGINT)"
else
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}"
fi
exit 130
}
# ------------------------------------------------------------------------------
# on_terminate()
#
# - SIGTERM trap handler
# - Displays "Terminated by signal" message
# - Exits with code 143 (128 + SIGTERM=15)
# - Triggered by external process termination
# ------------------------------------------------------------------------------
on_terminate() {
if declare -f msg_error >/dev/null 2>&1; then
msg_error "Terminated by signal (SIGTERM)"
else
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}"
fi
exit 143
}
# ==============================================================================
# SECTION 4: INITIALIZATION
# ==============================================================================
# ------------------------------------------------------------------------------
# catch_errors()
#
# - Initializes error handling and signal traps
# - Enables strict error handling:
# * set -Ee: Exit on error, inherit ERR trap in functions
# * set -o pipefail: Pipeline fails if any command fails
# * set -u: (optional) Exit on undefined variable (if STRICT_UNSET=1)
# - Sets up traps:
# * ERR → error_handler
# * EXIT → on_exit
# * INT → on_interrupt
# * TERM → on_terminate
# - Call this function early in every script
# ------------------------------------------------------------------------------
catch_errors() {
set -Ee -o pipefail
if [ "${STRICT_UNSET:-0}" = "1" ]; then
set -u
fi
trap 'error_handler' ERR
trap on_exit EXIT
trap on_interrupt INT
trap on_terminate TERM
}

View File

@@ -1,50 +1,91 @@
# Copyright (c) 2021-2025 michelroegl-brunner
# Author: michelroegl-brunner
# License: MIT
# https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Copyright (c) 2021-2026 community-scripts ORG
# Author: tteck (tteckster)
# Co-Author: MickLesk
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# ==============================================================================
# INSTALL.FUNC - CONTAINER INSTALLATION & SETUP
# ==============================================================================
#
# This file provides installation functions executed inside LXC containers
# after creation. Handles:
#
# - Network connectivity verification (IPv4/IPv6)
# - OS updates and package installation
# - DNS resolution checks
# - MOTD and SSH configuration
# - Container customization and auto-login
#
# Usage:
# - Sourced by <app>-install.sh scripts
# - Executes via pct exec inside container
# - Requires internet connectivity
#
# ==============================================================================
# ==============================================================================
# SECTION 1: INITIALIZATION
# ==============================================================================
if ! command -v curl >/dev/null 2>&1; then
printf "\r\e[2K%b" '\033[93m Setup Source \033[m' >&2
apt-get update >/dev/null 2>&1
apt-get install -y curl >/dev/null 2>&1
apt update >/dev/null 2>&1
apt install -y curl >/dev/null 2>&1
fi
# core.func is included in FUNCTIONS_FILE_PATH
source "$(dirname "${BASH_SOURCE[0]}")/core.func"
source "$(dirname "${BASH_SOURCE[0]}")/error-handler.func"
load_functions
# This function enables IPv6 if it's not disabled and sets verbose mode
catch_errors
# Get LXC IP address (must be called INSIDE container, after network is up)
get_lxc_ip
# ==============================================================================
# SECTION 2: NETWORK & CONNECTIVITY
# ==============================================================================
# ------------------------------------------------------------------------------
# verb_ip6()
#
# - Configures IPv6 based on DISABLEIPV6 variable
# - If DISABLEIPV6=yes: disables IPv6 via sysctl
# - Sets verbose mode via set_std_mode()
# ------------------------------------------------------------------------------
verb_ip6() {
set_std_mode # Set STD mode based on VERBOSE
if [ "$DISABLEIPV6" == "yes" ]; then
echo "net.ipv6.conf.all.disable_ipv6 = 1" >>/etc/sysctl.conf
$STD sysctl -p
if [ "${IPV6_METHOD:-}" = "disable" ]; then
msg_info "Disabling IPv6 (this may affect some services)"
mkdir -p /etc/sysctl.d
$STD tee /etc/sysctl.d/99-disable-ipv6.conf >/dev/null <<EOF
# Disable IPv6 (set by community-scripts)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
EOF
$STD sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
msg_ok "Disabled IPv6"
fi
}
# This function sets error handling options and defines the error_handler function to handle errors
catch_errors() {
set -Eeuo pipefail
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
# This function handles errors
error_handler() {
printf "\e[?25h"
local exit_code="$?"
local line_number="$1"
local command="$2"
local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}"
echo -e "\n$error_message"
if [[ "$line_number" -eq 51 ]]; then
echo -e "The silent function has suppressed the error, run the script with verbose mode enabled, which will provide more detailed output.\n"
post_update_to_api "failed" "No error message, script ran in silent mode"
else
post_update_to_api "failed" "${command}"
fi
}
# This function sets up the Container OS by generating the locale, setting the timezone, and checking the network connection
# ------------------------------------------------------------------------------
# setting_up_container()
#
# - Verifies network connectivity via hostname -I
# - Retries up to RETRY_NUM times with RETRY_EVERY seconds delay
# - Removes Python EXTERNALLY-MANAGED restrictions
# - Disables systemd-networkd-wait-online.service for faster boot
# - Exits with error if network unavailable after retries
# ------------------------------------------------------------------------------
setting_up_container() {
msg_info "Setting up Container OS"
# Fix Debian 13 LXC template bug where / is owned by nobody
# Only attempt in privileged containers (unprivileged cannot chown /)
if [[ "$(stat -c '%U' /)" != "root" ]]; then
(chown root:root / 2>/dev/null) || true
fi
for ((i = RETRY_NUM; i > 0; i--)); do
if [ "$(hostname -I)" != "" ]; then
break
@@ -64,8 +105,17 @@ setting_up_container() {
msg_ok "Network Connected: ${BL}$(hostname -I)"
}
# This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected
# This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected
# ------------------------------------------------------------------------------
# network_check()
#
# - Comprehensive network connectivity check for IPv4 and IPv6
# - Tests connectivity to multiple DNS servers:
# * IPv4: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9)
# * IPv6: 2606:4700:4700::1111, 2001:4860:4860::8888, 2620:fe::fe
# - Verifies DNS resolution for GitHub and Community-Scripts domains
# - Prompts user to continue if no internet detected
# - Uses fatal() on DNS resolution failure for critical hosts
# ------------------------------------------------------------------------------
network_check() {
set +e
trap - ERR
@@ -125,7 +175,19 @@ network_check() {
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
# This function updates the Container OS by running apt-get update and upgrade
# ==============================================================================
# SECTION 3: OS UPDATE & PACKAGE MANAGEMENT
# ==============================================================================
# ------------------------------------------------------------------------------
# update_os()
#
# - Updates container OS via apt-get update and dist-upgrade
# - Configures APT cacher proxy if CACHER=yes (accelerates package downloads)
# - Removes Python EXTERNALLY-MANAGED restrictions for pip
# - Sources tools.func for additional setup functions after update
# - Uses $STD wrapper to suppress output unless VERBOSE=yes
# ------------------------------------------------------------------------------
update_os() {
msg_info "Updating Container OS"
if [[ "$CACHER" == "yes" ]]; then
@@ -145,29 +207,37 @@ EOF
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED
msg_ok "Updated Container OS"
# tools.func is included in FUNCTIONS_FILE_PATH
source "$(dirname "${BASH_SOURCE[0]}")/tools.func"
}
# This function modifies the message of the day (motd) and SSH settings
# ==============================================================================
# SECTION 4: MOTD & SSH CONFIGURATION
# ==============================================================================
# ------------------------------------------------------------------------------
# motd_ssh()
#
# - Configures Message of the Day (MOTD) with container information
# - Creates /etc/profile.d/00_lxc-details.sh with:
# * Application name
# * Warning banner (DEV repository)
# * OS name and version
# * Hostname and IP address
# * GitHub repository link
# - Disables executable flag on /etc/update-motd.d/* scripts
# - Enables root SSH access if SSH_ROOT=yes
# - Configures TERM environment variable for better terminal support
# ------------------------------------------------------------------------------
motd_ssh() {
# Set terminal to 256-color mode
grep -qxF "export TERM='xterm-256color'" /root/.bashrc || echo "export TERM='xterm-256color'" >>/root/.bashrc
# Get OS information (Debian / Ubuntu)
if [ -f "/etc/os-release" ]; then
OS_NAME=$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '"')
OS_VERSION=$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '"')
elif [ -f "/etc/debian_version" ]; then
OS_NAME="Debian"
OS_VERSION=$(cat /etc/debian_version)
fi
PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
echo "echo -e \"\"" >"$PROFILE_FILE"
echo -e "echo -e \"${BOLD}${APPLICATION} LXC Container${CL}"\" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${GATEWAY}${YW} Provided by: ${GN}community-scripts ORG ${YW}| GitHub: ${GN}https://github.com/community-scripts/ProxmoxVE${CL}\"" >>"$PROFILE_FILE"
echo "echo \"\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${OS}${YW} OS: ${GN}${OS_NAME} - Version: ${OS_VERSION}${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${OS}${YW} OS: ${GN}\$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '\"') - Version: \$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '\"')${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${HOSTNAME}${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${INFO}${YW} IP Address: ${GN}\$(hostname -I | awk '{print \$1}')${CL}\"" >>"$PROFILE_FILE"
@@ -180,7 +250,19 @@ motd_ssh() {
fi
}
# This function customizes the container by modifying the getty service and enabling auto-login for the root user
# ==============================================================================
# SECTION 5: CONTAINER CUSTOMIZATION
# ==============================================================================
# ------------------------------------------------------------------------------
# customize()
#
# - Customizes container for passwordless root login if PASSWORD is empty
# - Configures getty for auto-login via /etc/systemd/system/container-getty@1.service.d/override.conf
# - Creates /usr/bin/update script for easy application updates
# - Injects SSH authorized keys if SSH_AUTHORIZED_KEY variable is set
# - Sets proper permissions on SSH directories and key files
# ------------------------------------------------------------------------------
customize() {
if [[ "$PASSWORD" == "" ]]; then
msg_info "Customizing Container"

File diff suppressed because it is too large Load Diff

44
scripts/ct/debian.sh Normal file
View File

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

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2026 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
motd_ssh
customize
cleanup_lxc

576
server.js
View File

@@ -8,9 +8,12 @@ import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
import { getDatabase } from './src/server/database-prisma.js';
import { initializeAutoSync, initializeRepositories, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js';
import dotenv from 'dotenv';
// Dynamic import for auto sync init to avoid tsx caching issues
/** @type {any} */
let autoSyncModule = null;
// Load environment variables from .env file
dotenv.config();
// Fallback minimal global error handlers for Node runtime (avoid TS import)
@@ -71,7 +74,15 @@ const handle = app.getRequestHandler();
* @property {ServerInfo} [server]
* @property {boolean} [isUpdate]
* @property {boolean} [isShell]
* @property {boolean} [isBackup]
* @property {boolean} [isClone]
* @property {string} [containerId]
* @property {string} [storage]
* @property {string} [backupStorage]
* @property {number} [cloneCount]
* @property {string[]} [hostnames]
* @property {'lxc'|'vm'} [containerType]
* @property {Record<string, string|number|boolean>} [envVars]
*/
class ScriptExecutionHandler {
@@ -79,14 +90,27 @@ class ScriptExecutionHandler {
* @param {import('http').Server} server
*/
constructor(server) {
// Create WebSocketServer without attaching to server
// We'll handle upgrades manually to avoid interfering with Next.js HMR
this.wss = new WebSocketServer({
server,
path: '/ws/script-execution'
noServer: true
});
this.activeExecutions = new Map();
this.db = getDatabase();
this.setupWebSocket();
}
/**
* Handle WebSocket upgrade for our endpoint
* @param {import('http').IncomingMessage} request
* @param {import('stream').Duplex} socket
* @param {Buffer} head
*/
handleUpgrade(request, socket, head) {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.wss.emit('connection', ws, request);
});
}
/**
* Parse Container ID from terminal output
@@ -276,19 +300,21 @@ class ScriptExecutionHandler {
* @param {WebSocketMessage} message
*/
async handleMessage(ws, message) {
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage } = message;
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, isClone, containerId, storage, backupStorage, cloneCount, hostnames, containerType, envVars } = message;
switch (action) {
case 'start':
if (scriptPath && executionId) {
if (isBackup && containerId && storage) {
if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) {
await this.startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames);
} else if (isBackup && containerId && storage) {
await this.startBackupExecution(ws, containerId, executionId, storage, mode, server);
} else if (isUpdate && containerId) {
await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage);
} else if (isShell && containerId) {
await this.startShellExecution(ws, containerId, executionId, mode, server);
} else {
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
await this.startScriptExecution(ws, scriptPath, executionId, mode, server, envVars);
}
} else {
this.sendMessage(ws, {
@@ -326,8 +352,9 @@ class ScriptExecutionHandler {
* @param {string} executionId
* @param {string} mode
* @param {ServerInfo|null} server
* @param {Object} [envVars] - Optional environment variables to pass to the script
*/
async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null) {
async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null, envVars = {}) {
/** @type {number|null} */
let installationId = null;
@@ -356,7 +383,7 @@ class ScriptExecutionHandler {
// Handle SSH execution
if (mode === 'ssh' && server) {
await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId);
await this.startSSHScriptExecution(ws, scriptPath, executionId, server, installationId, envVars);
return;
}
@@ -382,19 +409,32 @@ class ScriptExecutionHandler {
return;
}
// Format environment variables for local execution
// Convert envVars object to environment variables
const envWithVars = {
...process.env,
TERM: 'xterm-256color', // Enable proper terminal support
FORCE_ANSI: 'true', // Allow ANSI codes for proper display
COLUMNS: '80', // Set terminal width
LINES: '24' // Set terminal height
};
// Add envVars to environment
if (envVars && typeof envVars === 'object') {
for (const [key, value] of Object.entries(envVars)) {
/** @type {Record<string, string>} */
const envRecord = envWithVars;
envRecord[key] = String(value);
}
}
// Start script execution with pty for proper TTY support
const childProcess = ptySpawn('bash', [resolvedPath], {
cwd: scriptsDir,
name: 'xterm-256color',
cols: 80,
rows: 24,
env: {
...process.env,
TERM: 'xterm-256color', // Enable proper terminal support
FORCE_ANSI: 'true', // Allow ANSI codes for proper display
COLUMNS: '80', // Set terminal width
LINES: '24' // Set terminal height
}
env: envWithVars
});
// pty handles encoding automatically
@@ -497,8 +537,9 @@ class ScriptExecutionHandler {
* @param {string} executionId
* @param {ServerInfo} server
* @param {number|null} installationId
* @param {Object} [envVars] - Optional environment variables to pass to the script
*/
async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null) {
async startSSHScriptExecution(ws, scriptPath, executionId, server, installationId = null, envVars = {}) {
const sshService = getSSHExecutionService();
// Send start message
@@ -587,7 +628,8 @@ class ScriptExecutionHandler {
// Clean up
this.activeExecutions.delete(executionId);
}
},
envVars
));
// Store the execution with installation ID
@@ -707,7 +749,7 @@ class ScriptExecutionHandler {
* @param {ServerInfo} server
* @param {Function} [onComplete] - Optional callback when backup completes
*/
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = undefined) {
const sshService = getSSHExecutionService();
return new Promise((resolve, reject) => {
@@ -813,16 +855,432 @@ class ScriptExecutionHandler {
});
}
/**
* Start SSH clone execution
* Gets next IDs sequentially: get next ID → clone → get next ID → clone, etc.
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
* @param {string} storage
* @param {ServerInfo} server
* @param {'lxc'|'vm'} containerType
* @param {number} cloneCount
* @param {string[]} hostnames
*/
async startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames) {
const sshService = getSSHExecutionService();
this.sendMessage(ws, {
type: 'start',
data: `Starting clone operation: Creating ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}...`,
timestamp: Date.now()
});
try {
// Step 1: Stop source container/VM
this.sendMessage(ws, {
type: 'output',
data: `\n[Step 1/${4 + cloneCount}] Stopping source ${containerType.toUpperCase()} ${containerId}...\n`,
timestamp: Date.now()
});
const stopCommand = containerType === 'lxc' ? `pct stop ${containerId}` : `qm stop ${containerId}`;
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
sshService.executeCommand(
server,
stopCommand,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
if (code === 0) {
this.sendMessage(ws, {
type: 'output',
data: `\n[Step 1/${4 + cloneCount}] Source ${containerType.toUpperCase()} stopped successfully.\n`,
timestamp: Date.now()
});
resolve();
} else {
// Continue even if stop fails (might already be stopped)
this.sendMessage(ws, {
type: 'output',
data: `\n[Step 1/${4 + cloneCount}] Stop command completed with exit code ${code} (container may already be stopped).\n`,
timestamp: Date.now()
});
resolve();
}
}
);
}));
// Step 2: Clone for each clone count (get next ID sequentially before each clone)
const clonedIds = [];
for (let i = 0; i < cloneCount; i++) {
const cloneNumber = i + 1;
const hostname = hostnames[i];
// Get next ID for this clone
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + i}/${4 + cloneCount}] Getting next available ID for clone ${cloneNumber}...\n`,
timestamp: Date.now()
});
let nextId = '';
try {
let output = '';
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
sshService.executeCommand(
server,
'pvesh get /cluster/nextid',
/** @param {string} data */
(data) => {
output += data;
},
/** @param {string} error */
(error) => {
reject(new Error(`Failed to get next ID: ${error}`));
},
/** @param {number} exitCode */
(exitCode) => {
if (exitCode === 0) {
resolve();
} else {
reject(new Error(`pvesh command failed with exit code ${exitCode}`));
}
}
);
}));
nextId = output.trim();
if (!nextId || !/^\d+$/.test(nextId)) {
throw new Error('Invalid next ID received');
}
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + i}/${4 + cloneCount}] Got next ID: ${nextId}\n`,
timestamp: Date.now()
});
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `\n[Step ${2 + i}/${4 + cloneCount}] Failed to get next ID: ${error instanceof Error ? error.message : String(error)}\n`,
timestamp: Date.now()
});
throw error;
}
clonedIds.push(nextId);
// Clone the container/VM
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + i}/${4 + cloneCount}] Cloning ${containerType.toUpperCase()} ${containerId} to ${nextId} with hostname ${hostname}...\n`,
timestamp: Date.now()
});
const cloneCommand = containerType === 'lxc'
? `pct clone ${containerId} ${nextId} --hostname ${hostname} --storage ${storage}`
: `qm clone ${containerId} ${nextId} --name ${hostname} --storage ${storage}`;
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve, reject) => {
sshService.executeCommand(
server,
cloneCommand,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
if (code === 0) {
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + i}/${4 + cloneCount}] Clone ${cloneNumber} created successfully.\n`,
timestamp: Date.now()
});
resolve();
} else {
this.sendMessage(ws, {
type: 'error',
data: `\nClone ${cloneNumber} failed with exit code: ${code}\n`,
timestamp: Date.now()
});
reject(new Error(`Clone ${cloneNumber} failed with exit code ${code}`));
}
}
);
}));
}
// Step 3: Start source container/VM
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Starting source ${containerType.toUpperCase()} ${containerId}...\n`,
timestamp: Date.now()
});
const startSourceCommand = containerType === 'lxc' ? `pct start ${containerId}` : `qm start ${containerId}`;
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => {
sshService.executeCommand(
server,
startSourceCommand,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
if (code === 0) {
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Source ${containerType.toUpperCase()} started successfully.\n`,
timestamp: Date.now()
});
} else {
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + cloneCount + 1}/${4 + cloneCount}] Start command completed with exit code ${code}.\n`,
timestamp: Date.now()
});
}
resolve();
}
);
}));
// Step 4: Start target containers/VMs
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + cloneCount + 2}/${4 + cloneCount}] Starting cloned ${containerType.toUpperCase()}(s)...\n`,
timestamp: Date.now()
});
for (let i = 0; i < cloneCount; i++) {
const cloneNumber = i + 1;
const nextId = clonedIds[i];
const startTargetCommand = containerType === 'lxc' ? `pct start ${nextId}` : `qm start ${nextId}`;
await new Promise(/** @type {(resolve: (value?: void) => void, reject: (error?: any) => void) => void} */ ((resolve) => {
sshService.executeCommand(
server,
startTargetCommand,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
if (code === 0) {
this.sendMessage(ws, {
type: 'output',
data: `\nClone ${cloneNumber} (ID: ${nextId}) started successfully.\n`,
timestamp: Date.now()
});
} else {
this.sendMessage(ws, {
type: 'output',
data: `\nClone ${cloneNumber} (ID: ${nextId}) start completed with exit code ${code}.\n`,
timestamp: Date.now()
});
}
resolve();
}
);
}));
}
// Step 5: Add to database
this.sendMessage(ws, {
type: 'output',
data: `\n[Step ${2 + cloneCount + 3}/${4 + cloneCount}] Adding cloned ${containerType.toUpperCase()}(s) to database...\n`,
timestamp: Date.now()
});
for (let i = 0; i < cloneCount; i++) {
const nextId = clonedIds[i];
const hostname = hostnames[i];
try {
// Read config file to get hostname/name
const configPath = containerType === 'lxc'
? `/etc/pve/lxc/${nextId}.conf`
: `/etc/pve/qemu-server/${nextId}.conf`;
let configContent = '';
await new Promise(/** @type {(resolve: (value?: void) => void) => void} */ ((resolve) => {
sshService.executeCommand(
server,
`cat "${configPath}" 2>/dev/null || echo ""`,
/** @param {string} data */
(data) => {
configContent += data;
},
() => resolve(),
() => resolve()
);
}));
// Parse config for hostname/name
let finalHostname = hostname;
if (configContent.trim()) {
const lines = configContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (containerType === 'lxc' && trimmed.startsWith('hostname:')) {
finalHostname = trimmed.substring(9).trim();
break;
} else if (containerType === 'vm' && trimmed.startsWith('name:')) {
finalHostname = trimmed.substring(5).trim();
break;
}
}
}
if (!finalHostname) {
finalHostname = `${containerType}-${nextId}`;
}
// Create installed script record
const script = await this.db.createInstalledScript({
script_name: finalHostname,
script_path: `cloned/${finalHostname}`,
container_id: nextId,
server_id: server.id,
execution_mode: 'ssh',
status: 'success',
output_log: `Cloned ${containerType.toUpperCase()}`
});
// For LXC, store config in database
if (containerType === 'lxc' && configContent.trim()) {
// Simple config parser
/** @type {any} */
const configData = {};
const lines = configContent.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [key, ...valueParts] = trimmed.split(':');
const value = valueParts.join(':').trim();
if (key === 'hostname') configData.hostname = value;
else if (key === 'arch') configData.arch = value;
else if (key === 'cores') configData.cores = parseInt(value) || null;
else if (key === 'memory') configData.memory = parseInt(value) || null;
else if (key === 'swap') configData.swap = parseInt(value) || null;
else if (key === 'onboot') configData.onboot = parseInt(value) || null;
else if (key === 'ostype') configData.ostype = value;
else if (key === 'unprivileged') configData.unprivileged = parseInt(value) || null;
else if (key === 'tags') configData.tags = value;
else if (key === 'rootfs') {
const match = value.match(/^([^:]+):([^,]+)/);
if (match) {
configData.rootfs_storage = match[1];
const sizeMatch = value.match(/size=([^,]+)/);
if (sizeMatch) {
configData.rootfs_size = sizeMatch[1];
}
}
}
}
await this.db.createLXCConfig(script.id, configData);
}
this.sendMessage(ws, {
type: 'output',
data: `\nClone ${i + 1} (ID: ${nextId}, Hostname: ${finalHostname}) added to database successfully.\n`,
timestamp: Date.now()
});
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `\nError adding clone ${i + 1} (ID: ${nextId}) to database: ${error instanceof Error ? error.message : String(error)}\n`,
timestamp: Date.now()
});
}
}
this.sendMessage(ws, {
type: 'output',
data: `\n\n[Clone operation completed successfully!]\nCreated ${cloneCount} clone(s) of ${containerType.toUpperCase()} ${containerId}.\n`,
timestamp: Date.now()
});
this.activeExecutions.delete(executionId);
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `\n\n[Clone operation failed!]\nError: ${error instanceof Error ? error.message : String(error)}\n`,
timestamp: Date.now()
});
this.activeExecutions.delete(executionId);
}
}
/**
* Start update execution (pct enter + update command)
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
* @param {string} mode
* @param {ServerInfo|null} server
* @param {ServerInfo|undefined} server
* @param {string} [backupStorage] - Optional storage to backup to before update
*/
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) {
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = undefined, backupStorage = undefined) {
try {
// If backup storage is provided, run backup first
if (backupStorage && mode === 'ssh' && server) {
@@ -1152,6 +1610,7 @@ class ScriptExecutionHandler {
// TerminalHandler removed - not used by current application
app.prepare().then(() => {
console.log('> Next.js app prepared successfully');
const httpServer = createServer(async (req, res) => {
try {
// Be sure to pass `true` as the second argument to `url.parse`.
@@ -1159,12 +1618,22 @@ app.prepare().then(() => {
const parsedUrl = parse(req.url || '', true);
const { pathname, query } = parsedUrl;
if (pathname === '/ws/script-execution') {
// Check if this is a WebSocket upgrade request
const isWebSocketUpgrade = req.headers.upgrade === 'websocket';
// Only intercept WebSocket upgrades for /ws/script-execution
// Let Next.js handle all other WebSocket upgrades (like HMR) and all HTTP requests
if (isWebSocketUpgrade && pathname === '/ws/script-execution') {
// WebSocket upgrade will be handled by the WebSocket server
// Don't call handle() for this path - let WebSocketServer handle it
return;
}
// Let Next.js handle all other requests including HMR
// Let Next.js handle all other requests including:
// - HTTP requests to /ws/script-execution (non-WebSocket)
// - WebSocket upgrades to other paths (like /_next/webpack-hmr)
// - All static assets (_next routes)
// - All other routes
await handle(req, res, parsedUrl);
} catch (err) {
console.error('Error occurred handling', req.url, err);
@@ -1175,6 +1644,33 @@ app.prepare().then(() => {
// Create WebSocket handlers
const scriptHandler = new ScriptExecutionHandler(httpServer);
// Handle WebSocket upgrades manually to avoid interfering with Next.js HMR
// We need to preserve Next.js's upgrade handlers and call them for non-matching paths
// Save any existing upgrade listeners (Next.js might have set them up)
const existingUpgradeListeners = httpServer.listeners('upgrade').slice();
httpServer.removeAllListeners('upgrade');
// Add our upgrade handler that routes based on path
httpServer.on('upgrade', (request, socket, head) => {
const parsedUrl = parse(request.url || '', true);
const { pathname } = parsedUrl;
if (pathname === '/ws/script-execution') {
// Handle our custom WebSocket endpoint
scriptHandler.handleUpgrade(request, socket, head);
} else {
// For all other paths (including Next.js HMR), call existing listeners
// This allows Next.js to handle its own WebSocket upgrades
for (const listener of existingUpgradeListeners) {
try {
listener.call(httpServer, request, socket, head);
} catch (err) {
console.error('Error in upgrade listener:', err);
}
}
}
});
// Note: TerminalHandler removed as it's not being used by the current application
httpServer
@@ -1186,13 +1682,43 @@ app.prepare().then(() => {
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
// Initialize auto sync module and run initialization
if (!autoSyncModule) {
try {
console.log('Dynamically importing autoSyncInit...');
autoSyncModule = await import('./src/server/lib/autoSyncInit.js');
console.log('autoSyncModule loaded, exports:', Object.keys(autoSyncModule));
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
console.error('Failed to import autoSyncInit:', err.message);
console.error('Stack:', err.stack);
throw error;
}
}
// Initialize default repositories
await initializeRepositories();
if (typeof autoSyncModule.initializeRepositories === 'function') {
console.log('Calling initializeRepositories...');
await autoSyncModule.initializeRepositories();
} else {
console.warn('initializeRepositories is not a function, type:', typeof autoSyncModule.initializeRepositories);
}
// Initialize auto-sync service
initializeAutoSync();
if (typeof autoSyncModule.initializeAutoSync === 'function') {
console.log('Calling initializeAutoSync...');
autoSyncModule.initializeAutoSync();
}
// Setup graceful shutdown handlers
setupGracefulShutdown();
if (typeof autoSyncModule.setupGracefulShutdown === 'function') {
console.log('Setting up graceful shutdown...');
autoSyncModule.setupGracefulShutdown();
}
});
}).catch((err) => {
console.error('> Failed to start server:', err.message);
console.error('> If you see "Could not find a production build", run: npm run build');
console.error('> Full error:', err);
process.exit(1);
});

View File

@@ -1,6 +1,13 @@
'use client';
"use client";
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
interface AuthContextType {
isAuthenticated: boolean;
@@ -27,10 +34,13 @@ export function AuthProvider({ children }: AuthProviderProps) {
const checkAuthInternal = async (retryCount = 0) => {
try {
// First check if setup is completed
const setupResponse = await fetch('/api/settings/auth-credentials');
const setupResponse = await fetch("/api/settings/auth-credentials");
if (setupResponse.ok) {
const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
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);
@@ -42,12 +52,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
}
// Only verify authentication if setup is completed and auth is enabled
const response = await fetch('/api/auth/verify', {
credentials: 'include', // Ensure cookies are sent
const response = await fetch("/api/auth/verify", {
credentials: "include", // Ensure cookies are sent
});
if (response.ok) {
const data = await response.json() as {
username: string;
const data = (await response.json()) as {
username: string;
expirationTime?: number | null;
timeUntilExpiration?: number | null;
};
@@ -58,7 +68,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
// Retry logic for failed auth checks (max 2 retries)
if (retryCount < 2) {
setTimeout(() => {
@@ -68,11 +78,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
}
}
} catch (error) {
console.error('Error checking auth:', error);
console.error("Error checking auth:", error);
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
// Retry logic for network errors (max 2 retries)
if (retryCount < 2) {
setTimeout(() => {
@@ -89,44 +99,49 @@ export function AuthProvider({ children }: AuthProviderProps) {
return checkAuthInternal(0);
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
const login = async (
username: string,
password: string,
): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
credentials: 'include', // Ensure cookies are received
credentials: "include", // Ensure cookies are received
});
if (response.ok) {
const data = await response.json() as { username: string };
const data = (await response.json()) as {
username: string;
expirationTime?: number;
};
setIsAuthenticated(true);
setUsername(data.username);
// Check auth again to get expiration time
// Add a small delay to ensure the httpOnly cookie is available
await new Promise<void>((resolve) => {
setTimeout(() => {
void checkAuth().then(() => resolve());
}, 150);
});
// Set expiration time from login response if available
if (data.expirationTime) {
setExpirationTime(data.expirationTime);
}
// Don't call checkAuth after login - we already know we're authenticated
// The cookie is set by the server response
return true;
} else {
const errorData = await response.json();
console.error('Login failed:', errorData.error);
console.error("Login failed:", errorData.error);
return false;
}
} catch (error) {
console.error('Login error:', 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=/;';
document.cookie =
"auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
setIsAuthenticated(false);
setUsername(null);
setExpirationTime(null);
@@ -156,7 +171,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -1,8 +1,8 @@
'use client';
"use client";
import { Button } from './ui/button';
import { AlertTriangle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { Button } from "./ui/button";
import { AlertTriangle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
interface BackupWarningModalProps {
isOpen: boolean;
@@ -13,33 +13,43 @@ interface BackupWarningModalProps {
export function BackupWarningModal({
isOpen,
onClose,
onProceed
onProceed,
}: BackupWarningModalProps) {
useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose });
useRegisterModal(isOpen, {
id: "backup-warning-modal",
allowEscape: true,
onClose,
});
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-warning" />
<h2 className="text-2xl font-bold text-card-foreground">Backup Failed</h2>
<AlertTriangle className="text-warning h-8 w-8" />
<h2 className="text-card-foreground text-2xl font-bold">
Backup Failed
</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-sm text-muted-foreground mb-6">
The backup failed, but you can still proceed with the update if you wish.
<br /><br />
<strong className="text-foreground">Warning:</strong> Proceeding without a backup means you won't be able to restore the container if something goes wrong during the update.
<p className="text-muted-foreground mb-6 text-sm">
The backup failed, but you can still proceed with the update if you
wish.
<br />
<br />
<strong className="text-foreground">Warning:</strong> Proceeding
without a backup means you won&apos;t be able to restore the
container if something goes wrong during the update.
</p>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3">
<div className="flex flex-col justify-end gap-3 sm:flex-row">
<Button
onClick={onClose}
variant="outline"
@@ -52,7 +62,7 @@ export function BackupWarningModal({
onClick={onProceed}
variant="default"
size="default"
className="w-full sm:w-auto bg-warning hover:bg-warning/90"
className="bg-warning hover:bg-warning/90 w-full sm:w-auto"
>
Proceed Anyway
</Button>
@@ -62,6 +72,3 @@ export function BackupWarningModal({
</div>
);
}

View File

@@ -1,18 +1,27 @@
'use client';
"use client";
import { useState, useEffect } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react';
import { useState, useEffect } from "react";
import { api } from "~/trpc/react";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import {
RefreshCw,
ChevronDown,
ChevronRight,
HardDrive,
Database,
Server,
CheckCircle,
AlertCircle,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { ConfirmationModal } from './ConfirmationModal';
import { LoadingModal } from './LoadingModal';
} from "./ui/dropdown-menu";
import { ConfirmationModal } from "./ConfirmationModal";
import { LoadingModal } from "./LoadingModal";
interface Backup {
id: number;
@@ -23,7 +32,7 @@ interface Backup {
storage_name: string;
storage_type: string;
discovered_at: Date;
server_id: number;
server_id?: number;
server_name: string | null;
server_color: string | null;
}
@@ -35,16 +44,25 @@ interface ContainerBackups {
}
export function BackupsTab() {
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set());
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(
new Set(),
);
const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false);
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
const [selectedBackup, setSelectedBackup] = useState<{ backup: Backup; containerId: string } | null>(null);
const [selectedBackup, setSelectedBackup] = useState<{
backup: Backup;
containerId: string;
} | null>(null);
const [restoreProgress, setRestoreProgress] = useState<string[]>([]);
const [restoreSuccess, setRestoreSuccess] = useState(false);
const [restoreError, setRestoreError] = useState<string | null>(null);
const [shouldPollRestore, setShouldPollRestore] = useState(false);
const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
const {
data: backupsData,
refetch: refetchBackups,
isLoading,
} = api.backups.getAllBackupsGrouped.useQuery();
const discoverMutation = api.backups.discoverBackups.useMutation({
onSuccess: () => {
void refetchBackups();
@@ -52,26 +70,30 @@ export function BackupsTab() {
});
// Poll for restore progress
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, {
enabled: shouldPollRestore,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
});
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(
undefined,
{
enabled: shouldPollRestore,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
},
);
// Update restore progress when log data changes
useEffect(() => {
if (restoreLogsData?.success && restoreLogsData.logs) {
setRestoreProgress(restoreLogsData.logs);
// Stop polling when restore is complete
if (restoreLogsData.isComplete) {
setShouldPollRestore(false);
// Check if restore was successful or failed
const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || '';
if (lastLog.includes('Restore completed successfully')) {
const lastLog =
restoreLogsData.logs[restoreLogsData.logs.length - 1] ?? "";
if (lastLog.includes("Restore completed successfully")) {
setRestoreSuccess(true);
setRestoreError(null);
} else if (lastLog.includes('Error:') || lastLog.includes('failed')) {
} else if (lastLog.includes("Error:") || lastLog.includes("failed")) {
setRestoreError(lastLog);
setRestoreSuccess(false);
}
@@ -83,17 +105,22 @@ export function BackupsTab() {
onMutate: () => {
// Start polling for progress
setShouldPollRestore(true);
setRestoreProgress(['Starting restore...']);
setRestoreProgress(["Starting restore..."]);
setRestoreError(null);
setRestoreSuccess(false);
},
onSuccess: (result) => {
// Stop polling - progress will be updated from logs
setShouldPollRestore(false);
if (result.success) {
// Update progress with all messages from backend (fallback if polling didn't work)
const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']);
const progressMessages =
restoreProgress.length > 0
? restoreProgress
: (result.progress?.map((p) => p.message) ?? [
"Restore completed successfully",
]);
setRestoreProgress(progressMessages);
setRestoreSuccess(true);
setRestoreError(null);
@@ -101,8 +128,10 @@ export function BackupsTab() {
setSelectedBackup(null);
// Keep success message visible - user can dismiss manually
} else {
setRestoreError(result.error || 'Restore failed');
setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress);
setRestoreError(result.error ?? "Restore failed");
setRestoreProgress(
result.progress?.map((p) => p.message) ?? restoreProgress,
);
setRestoreSuccess(false);
setRestoreConfirmOpen(false);
setSelectedBackup(null);
@@ -112,24 +141,25 @@ export function BackupsTab() {
onError: (error) => {
// Stop polling on error
setShouldPollRestore(false);
setRestoreError(error.message || 'Restore failed');
setRestoreError(error.message ?? "Restore failed");
setRestoreConfirmOpen(false);
setSelectedBackup(null);
setRestoreProgress([]);
},
});
// Update progress text in modal based on current progress
const currentProgressText = restoreProgress.length > 0
? restoreProgress[restoreProgress.length - 1]
: 'Restoring backup...';
const currentProgressText =
restoreProgress.length > 0
? restoreProgress[restoreProgress.length - 1]
: "Restoring backup...";
// Auto-discover backups when tab is first opened
useEffect(() => {
if (!hasAutoDiscovered && !isLoading && backupsData) {
// Only auto-discover if there are no backups yet
if (!backupsData.backups || backupsData.backups.length === 0) {
handleDiscoverBackups();
if (!backupsData.backups?.length) {
void handleDiscoverBackups();
}
setHasAutoDiscovered(true);
}
@@ -149,15 +179,15 @@ export function BackupsTab() {
const handleRestoreConfirm = () => {
if (!selectedBackup) return;
setRestoreConfirmOpen(false);
setRestoreError(null);
setRestoreSuccess(false);
restoreMutation.mutate({
backupId: selectedBackup.backup.id,
containerId: selectedBackup.containerId,
serverId: selectedBackup.backup.server_id,
serverId: selectedBackup.backup.server_id ?? 0,
});
};
@@ -172,39 +202,41 @@ export function BackupsTab() {
};
const formatFileSize = (bytes: bigint | null): string => {
if (!bytes) return 'Unknown size';
if (!bytes) return "Unknown size";
const b = Number(bytes);
if (b === 0) return '0 B';
if (b === 0) return "0 B";
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(b) / Math.log(k));
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
const formatDate = (date: Date | null): string => {
if (!date) return 'Unknown date';
if (!date) return "Unknown date";
return new Date(date).toLocaleString();
};
const getStorageTypeIcon = (type: string) => {
switch (type) {
case 'pbs':
case "pbs":
return <Database className="h-4 w-4" />;
case 'local':
case "local":
return <HardDrive className="h-4 w-4" />;
default:
return <Server className="h-4 w-4" />;
}
};
const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => {
const getStorageTypeBadgeVariant = (
type: string,
): "default" | "secondary" | "outline" => {
switch (type) {
case 'pbs':
return 'default';
case 'local':
return 'secondary';
case "pbs":
return "default";
case "local":
return "secondary";
default:
return 'outline';
return "outline";
}
};
@@ -216,8 +248,8 @@ export function BackupsTab() {
{/* Header with refresh button */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-foreground">Backups</h2>
<p className="text-sm text-muted-foreground mt-1">
<h2 className="text-foreground text-2xl font-bold">Backups</h2>
<p className="text-muted-foreground mt-1 text-sm">
Discovered backups grouped by container ID
</p>
</div>
@@ -226,31 +258,38 @@ export function BackupsTab() {
disabled={isDiscovering}
className="flex items-center gap-2"
>
<RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} />
{isDiscovering ? 'Discovering...' : 'Discover Backups'}
<RefreshCw
className={`h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
/>
{isDiscovering ? "Discovering..." : "Discover Backups"}
</Button>
</div>
{/* Loading state */}
{(isLoading || isDiscovering) && backups.length === 0 && (
<div className="bg-card rounded-lg border border-border p-8 text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
<div className="bg-card border-border rounded-lg border p-8 text-center">
<RefreshCw className="text-muted-foreground mx-auto mb-4 h-8 w-8 animate-spin" />
<p className="text-muted-foreground">
{isDiscovering ? 'Discovering backups...' : 'Loading backups...'}
{isDiscovering ? "Discovering backups..." : "Loading backups..."}
</p>
</div>
)}
{/* Empty state */}
{!isLoading && !isDiscovering && backups.length === 0 && (
<div className="bg-card rounded-lg border border-border p-8 text-center">
<HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
<div className="bg-card border-border rounded-lg border p-8 text-center">
<HardDrive className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="text-foreground mb-2 text-lg font-semibold">
No backups found
</h3>
<p className="text-muted-foreground mb-4">
Click "Discover Backups" to scan for backups on your servers.
Click &quot;Discover Backups&quot; to scan for backups on your
servers.
</p>
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
<RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
<RefreshCw
className={`mr-2 h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
/>
Discover Backups
</Button>
</div>
@@ -266,33 +305,35 @@ export function BackupsTab() {
return (
<div
key={container.container_id}
className="bg-card rounded-lg border border-border shadow-sm overflow-hidden"
className="bg-card border-border overflow-hidden rounded-lg border shadow-sm"
>
{/* Container header - collapsible */}
<button
onClick={() => toggleContainer(container.container_id)}
className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors text-left"
className="hover:bg-accent/50 flex w-full items-center justify-between p-4 text-left transition-colors"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex min-w-0 flex-1 items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<ChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<ChevronRight className="text-muted-foreground h-5 w-5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground font-semibold">
CT {container.container_id}
</span>
{container.hostname && (
<>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{container.hostname}</span>
<span className="text-muted-foreground">
{container.hostname}
</span>
</>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{backupCount} {backupCount === 1 ? 'backup' : 'backups'}
<p className="text-muted-foreground mt-1 text-sm">
{backupCount} {backupCount === 1 ? "backup" : "backups"}
</p>
</div>
</div>
@@ -300,28 +341,30 @@ export function BackupsTab() {
{/* Container content - backups list */}
{isExpanded && (
<div className="border-t border-border">
<div className="p-4 space-y-3">
<div className="border-border border-t">
<div className="space-y-3 p-4">
{container.backups.map((backup) => (
<div
key={backup.id}
className="bg-muted/50 rounded-lg p-4 border border-border/50"
className="bg-muted/50 border-border/50 rounded-lg border p-4"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="font-medium text-foreground break-all">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-foreground font-medium break-all">
{backup.backup_name}
</span>
<Badge
variant={getStorageTypeBadgeVariant(backup.storage_type)}
variant={getStorageTypeBadgeVariant(
backup.storage_type,
)}
className="flex items-center gap-1"
>
{getStorageTypeIcon(backup.storage_type)}
{backup.storage_name}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="text-muted-foreground flex flex-wrap items-center gap-4 text-sm">
{backup.size && (
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
@@ -339,7 +382,7 @@ export function BackupsTab() {
)}
</div>
<div className="mt-2">
<code className="text-xs text-muted-foreground break-all">
<code className="text-muted-foreground text-xs break-all">
{backup.backup_path}
</code>
</div>
@@ -350,14 +393,19 @@ export function BackupsTab() {
<Button
variant="outline"
size="sm"
className="bg-muted/20 hover:bg-muted/30 border border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground transition-all duration-200 hover:scale-105 hover:shadow-md"
className="bg-muted/20 hover:bg-muted/30 border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground border transition-all duration-200 hover:scale-105 hover:shadow-md"
>
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48 bg-card border-border">
<DropdownMenuContent className="bg-card border-border w-48">
<DropdownMenuItem
onClick={() => handleRestoreClick(backup, container.container_id)}
onClick={() =>
handleRestoreClick(
backup,
container.container_id,
)
}
disabled={restoreMutation.isPending}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
@@ -386,9 +434,9 @@ export function BackupsTab() {
{/* Error state */}
{backupsData && !backupsData.success && (
<div className="bg-destructive/10 border border-destructive rounded-lg p-4">
<div className="bg-destructive/10 border-destructive rounded-lg border p-4">
<p className="text-destructive">
Error loading backups: {backupsData.error || 'Unknown error'}
Error loading backups: {backupsData.error ?? "Unknown error"}
</p>
</div>
)}
@@ -412,7 +460,8 @@ export function BackupsTab() {
)}
{/* Restore Progress Modal */}
{(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
{(restoreMutation.isPending ||
(restoreSuccess && restoreProgress.length > 0)) && (
<LoadingModal
isOpen={true}
action={currentProgressText}
@@ -428,11 +477,13 @@ export function BackupsTab() {
{/* Restore Success */}
{restoreSuccess && (
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="bg-success/10 border-success/20 rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-success" />
<span className="font-medium text-success">Restore Completed Successfully</span>
<CheckCircle className="text-success h-5 w-5" />
<span className="text-success font-medium">
Restore Completed Successfully
</span>
</div>
<Button
variant="ghost"
@@ -446,7 +497,7 @@ export function BackupsTab() {
×
</Button>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
The container has been restored from backup.
</p>
</div>
@@ -454,11 +505,11 @@ export function BackupsTab() {
{/* Restore Error */}
{restoreError && (
<div className="bg-error/10 border border-error/20 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="bg-error/10 border-error/20 rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-error" />
<span className="font-medium text-error">Restore Failed</span>
<AlertCircle className="text-error h-5 w-5" />
<span className="text-error font-medium">Restore Failed</span>
</div>
<Button
variant="ghost"
@@ -472,13 +523,11 @@ export function BackupsTab() {
×
</Button>
</div>
<p className="text-sm text-muted-foreground">
{restoreError}
</p>
<p className="text-muted-foreground text-sm">{restoreError}</p>
{restoreProgress.length > 0 && (
<div className="space-y-1 mt-2">
<div className="mt-2 space-y-1">
{restoreProgress.map((message, index) => (
<p key={index} className="text-sm text-muted-foreground">
<p key={index} className="text-muted-foreground text-sm">
{message}
</p>
))}
@@ -500,4 +549,3 @@ export function BackupsTab() {
</div>
);
}

View File

@@ -187,9 +187,10 @@ export function CategorySidebar({
'Miscellaneous': 'box'
};
// Sort categories by count (descending) and then alphabetically
// Filter categories to only show those with scripts, then sort by count (descending) and alphabetically
const sortedCategories = categories
.map(category => [category, categoryCounts[category] ?? 0] as const)
.filter(([, count]) => count > 0) // Only show categories with at least one script
.sort(([a, countA], [b, countB]) => {
if (countB !== countA) return countB - countA;
return a.localeCompare(b);

View File

@@ -0,0 +1,129 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Copy, X } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
interface CloneCountInputModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (count: number) => void;
storageName: string;
}
export function CloneCountInputModal({
isOpen,
onClose,
onSubmit,
storageName
}: CloneCountInputModalProps) {
const [cloneCount, setCloneCount] = useState<number>(1);
useRegisterModal(isOpen, { id: 'clone-count-input-modal', allowEscape: true, onClose });
useEffect(() => {
if (isOpen) {
setCloneCount(1); // Reset to default when modal opens
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = () => {
if (cloneCount >= 1) {
onSubmit(cloneCount);
setCloneCount(1); // Reset after submit
}
};
const handleClose = () => {
setCloneCount(1); // Reset on close
onClose();
};
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-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<Copy className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">Clone Count</h2>
</div>
<Button
onClick={handleClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Content */}
<div className="p-6">
<p className="text-sm text-muted-foreground mb-4">
How many clones would you like to create?
</p>
{storageName && (
<div className="mb-4 p-3 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground">Storage:</p>
<p className="text-sm font-medium text-foreground">{storageName}</p>
</div>
)}
<div className="space-y-2 mb-6">
<label htmlFor="cloneCount" className="block text-sm font-medium text-foreground">
Number of Clones
</label>
<Input
id="cloneCount"
type="number"
min="1"
max="100"
value={cloneCount}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value >= 1 && value <= 100) {
setCloneCount(value);
} else if (e.target.value === '') {
setCloneCount(1);
}
}}
className="w-full"
placeholder="1"
/>
<p className="text-xs text-muted-foreground">
Enter a number between 1 and 100
</p>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3">
<Button
onClick={handleClose}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={cloneCount < 1 || cloneCount > 100}
variant="default"
size="default"
className="w-full sm:w-auto"
>
Continue
</Button>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +1,94 @@
'use client';
"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';
import { getDefaultFilters, mergeFiltersWithDefaults } from './filterUtils';
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 { ConfirmationModal } from "./ConfirmationModal";
import { Button } from "./ui/button";
import { RefreshCw } from "lucide-react";
import type { ScriptCard as ScriptCardType } from "~/types/script";
import type { Server } from "~/types/server";
import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils";
interface DownloadedScriptsTabProps {
onInstallScript?: (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: any,
server?: Server,
) => void;
}
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
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 [viewMode, setViewMode] = useState<"card" | "list">("card");
const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const [updateAllConfirmOpen, setUpdateAllConfirmOpen] = useState(false);
const [updateResult, setUpdateResult] = useState<{
successCount: number;
failCount: number;
failed: { slug: string; error: string }[];
} | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
const utils = api.useUtils();
const {
data: scriptCardsData,
isLoading: githubLoading,
error: githubError,
refetch,
} = api.scripts.getScriptCardsWithCategories.useQuery();
const {
data: localScriptsData,
isLoading: localLoading,
error: localError,
} = api.scripts.getAllDownloadedScripts.useQuery();
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
{ slug: selectedSlug ?? '' },
{ enabled: !!selectedSlug }
{ slug: selectedSlug ?? "" },
{ enabled: !!selectedSlug },
);
const loadMultipleScriptsMutation = api.scripts.loadMultipleScripts.useMutation({
onSuccess: (data) => {
void utils.scripts.getAllDownloadedScripts.invalidate();
void utils.scripts.getScriptCardsWithCategories.invalidate();
setUpdateResult({
successCount: data.successful?.length ?? 0,
failCount: data.failed?.length ?? 0,
failed: (data.failed ?? []).map((f) => ({
slug: f.slug,
error: f.error ?? "Unknown error",
})),
});
setTimeout(() => setUpdateResult(null), 8000);
},
onError: (error) => {
setUpdateResult({
successCount: 0,
failCount: 1,
failed: [{ slug: "Request failed", error: error.message }],
});
setTimeout(() => setUpdateResult(null), 8000);
},
});
// 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');
const saveFilterResponse = await fetch("/api/settings/save-filter");
let saveFilterEnabled = false;
if (saveFilterResponse.ok) {
const saveFilterData = await saveFilterResponse.json();
@@ -53,9 +98,11 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Load saved filters if SAVE_FILTER is enabled
if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters');
const filtersResponse = await fetch("/api/settings/filters");
if (filtersResponse.ok) {
const filtersData = await filtersResponse.json();
const filtersData = (await filtersResponse.json()) as {
filters?: Partial<FilterState>;
};
if (filtersData.filters) {
setFilters(mergeFiltersWithDefaults(filtersData.filters));
}
@@ -63,16 +110,20 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
}
// Load view mode
const viewModeResponse = await fetch('/api/settings/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')) {
if (
viewMode &&
typeof viewMode === "string" &&
(viewMode === "card" || viewMode === "list")
) {
setViewMode(viewMode);
}
}
} catch (error) {
console.error('Error loading settings:', error);
console.error("Error loading settings:", error);
} finally {
setIsLoadingFilters(false);
}
@@ -87,15 +138,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const saveFilters = async () => {
try {
await fetch('/api/settings/filters', {
method: 'POST',
await fetch("/api/settings/filters", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ filters }),
});
} catch (error) {
console.error('Error saving filters:', error);
console.error("Error saving filters:", error);
}
};
@@ -110,15 +161,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
const saveViewMode = async () => {
try {
await fetch('/api/settings/view-mode', {
method: 'POST',
await fetch("/api/settings/view-mode", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ viewMode }),
});
} catch (error) {
console.error('Error saving view mode:', error);
console.error("Error saving view mode:", error);
}
};
@@ -129,31 +180,32 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Extract categories from metadata
const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
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');
.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 => {
scriptCardsData.cards?.forEach((script: ScriptCardType) => {
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,
source: "github" as const,
isDownloaded: false, // Will be updated by status check
isUpToDate: false, // Will be updated by status check
isUpToDate: false, // Will be updated by status check
});
}
}
@@ -165,68 +217,77 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Update scripts with download status and filter to only downloaded scripts
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
// Helper to normalize identifiers so underscores vs hyphens don't break matches
const normalizeId = (s?: string): string => (s ?? '')
.toLowerCase()
.replace(/\.(sh|bash|py|js|ts)$/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const normalizeId = (s?: string): string =>
(s ?? "")
.toLowerCase()
.replace(/\.(sh|bash|py|js|ts)$/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return combinedScripts
.map(script => {
.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;
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
if (local.slug && script.slug) {
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
return true;
const hasLocalVersion =
localScriptsData?.scripts?.some((local) => {
if (!local?.name) return false;
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
if (local.slug && script.slug) {
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
return true;
}
}
}
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
// Only use normalized matching for install basenames, not for slug/name matching
const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
return matchesInstallBasename;
}) ?? false;
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
// Only use normalized matching for install basenames, not for slug/name matching
const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename =
(script as any)?.install_basenames?.some(
(base: string) => normalizeId(base) === normalizedLocal,
) ?? false;
return matchesInstallBasename;
}) ?? false;
return {
...script,
isDownloaded: hasLocalVersion,
};
})
.filter(script => script.isDownloaded); // Only show downloaded scripts
.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 => {
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)) {
if (
typeof categoryName === "string" &&
counts[categoryName] !== undefined &&
!countedCategories.has(categoryName)
) {
countedCategories.add(categoryName);
counts[categoryName]++;
}
});
}
});
return counts;
}, [categories, downloadedScripts, scriptCardsData?.success]);
@@ -237,15 +298,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// 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') {
scripts = scripts.filter((script) => {
if (!script || typeof script !== "object") {
return false;
}
const name = (script.name ?? '').toLowerCase();
const slug = (script.slug ?? '').toLowerCase();
const name = (script.name ?? "").toLowerCase();
const slug = (script.slug ?? "").toLowerCase();
return name.includes(query) ?? slug.includes(query);
});
@@ -254,9 +315,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Filter by category using real category data from downloaded scripts
if (selectedCategory) {
scripts = scripts.filter(script => {
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;
});
@@ -264,7 +325,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Filter by updateable status
if (filters.showUpdatable !== null) {
scripts = scripts.filter(script => {
scripts = scripts.filter((script) => {
if (!script) return false;
const isUpdatable = script.updateable ?? false;
return filters.showUpdatable ? isUpdatable : !isUpdatable;
@@ -273,28 +334,30 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Filter by script types
if (filters.selectedTypes.length > 0) {
scripts = scripts.filter(script => {
scripts = scripts.filter((script) => {
if (!script) return false;
const scriptType = (script.type ?? '').toLowerCase();
const scriptType = (script.type ?? "").toLowerCase();
// Map non-standard types to standard categories
const mappedType = scriptType === 'turnkey' ? 'ct' : scriptType;
return filters.selectedTypes.some(type => type.toLowerCase() === mappedType);
const mappedType = scriptType === "turnkey" ? "ct" : scriptType;
return filters.selectedTypes.some(
(type) => type.toLowerCase() === mappedType,
);
});
}
// Filter by repositories
if (filters.selectedRepositories.length > 0) {
scripts = scripts.filter(script => {
scripts = scripts.filter((script) => {
if (!script) return false;
const repoUrl = script.repository_url;
// If script has no repository_url, exclude it when filtering by repositories
if (!repoUrl) {
return false;
}
// Only include scripts from selected repositories
return filters.selectedRepositories.includes(repoUrl);
});
@@ -303,18 +366,18 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// 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 ?? '');
case "name":
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
break;
case 'created':
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 ?? '';
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)
@@ -327,15 +390,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
compareValue = 1;
} else {
// Both have no dates, fallback to name comparison
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
}
break;
default:
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
}
// Apply sort order
return filters.sortOrder === 'asc' ? compareValue : -compareValue;
return filters.sortOrder === "asc" ? compareValue : -compareValue;
});
return scripts;
@@ -343,8 +406,10 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
// Calculate filter counts for FilterBar
const filterCounts = React.useMemo(() => {
const updatableCount = downloadedScripts.filter(script => script?.updateable).length;
const updatableCount = downloadedScripts.filter(
(script) => script?.updateable,
).length;
return { installedCount: downloadedScripts.length, updatableCount };
}, [downloadedScripts]);
@@ -362,13 +427,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
useEffect(() => {
if (selectedCategory && gridRef.current) {
const timeoutId = setTimeout(() => {
gridRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
gridRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
inline: "nearest",
});
}, 100);
return () => clearTimeout(timeoutId);
}
}, [selectedCategory]);
@@ -384,25 +449,56 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
setSelectedSlug(null);
};
const handleUpdateAllClick = () => {
setUpdateResult(null);
setUpdateAllConfirmOpen(true);
};
const handleUpdateAllConfirm = () => {
setUpdateAllConfirmOpen(false);
const slugs = downloadedScripts
.map((s) => s.slug)
.filter((slug): slug is string => Boolean(slug));
if (slugs.length > 0) {
loadMultipleScriptsMutation.mutate({ slugs });
}
};
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-primary"></div>
<span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
<span className="text-muted-foreground ml-2">
Loading downloaded scripts...
</span>
</div>
);
}
if (githubError || localError) {
return (
<div className="text-center py-12">
<div className="py-12 text-center">
<div className="text-error 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
className="mx-auto mb-2 h-12 w-12"
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 className="text-lg font-medium">
Failed to load downloaded scripts
</p>
<p className="text-muted-foreground mt-1 text-sm">
{githubError?.message ??
localError?.message ??
"Unknown error occurred"}
</p>
</div>
<Button
@@ -419,14 +515,25 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
if (!downloadedScripts?.length) {
return (
<div className="text-center py-12">
<div className="py-12 text-center">
<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
className="mx-auto mb-4 h-12 w-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p className="text-lg font-medium">No downloaded scripts found</p>
<p className="text-sm text-muted-foreground mt-1">
You haven&apos;t downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
<p className="text-muted-foreground mt-1 text-sm">
You haven&apos;t downloaded any scripts yet. Visit the Available
Scripts tab to download some scripts.
</p>
</div>
</div>
@@ -435,12 +542,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
return (
<div className="space-y-6">
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
{/* Category Sidebar */}
<div className="flex-shrink-0 order-2 lg:order-1">
<div className="order-2 flex-shrink-0 lg:order-1">
<CategorySidebar
categories={categories}
categoryCounts={categoryCounts}
@@ -451,7 +555,44 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
</div>
{/* Main Content */}
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
<div className="order-1 min-w-0 flex-1 lg:order-2" ref={gridRef}>
{/* Update all downloaded scripts */}
<div className="mb-4 flex flex-wrap items-center gap-3">
<Button
onClick={handleUpdateAllClick}
disabled={loadMultipleScriptsMutation.isPending}
variant="secondary"
size="default"
className="flex items-center gap-2"
>
{loadMultipleScriptsMutation.isPending ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
<span>Updating...</span>
</>
) : (
<>
<RefreshCw className="h-4 w-4" />
<span>Update all downloaded scripts</span>
</>
)}
</Button>
{updateResult && (
<span className="text-muted-foreground text-sm">
Updated {updateResult.successCount} successfully
{updateResult.failCount > 0
? `, ${updateResult.failCount} failed`
: ""}
.
{updateResult.failCount > 0 && updateResult.failed.length > 0 && (
<span className="ml-1" title={updateResult.failed.map((f) => `${f.slug}: ${f.error}`).join("\n")}>
(hover for details)
</span>
)}
</span>
)}
</div>
{/* Enhanced Filter Bar */}
<FilterBar
filters={filters}
@@ -464,26 +605,41 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
/>
{/* View Toggle */}
<ViewToggle
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
<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">
{filteredScripts.length === 0 &&
(filters.searchQuery ||
selectedCategory ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0) ? (
<div className="py-12 text-center">
<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
className="mx-auto mb-4 h-12 w-12"
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">
<p className="text-lg font-medium">
No matching downloaded scripts found
</p>
<p className="text-muted-foreground mt-1 text-sm">
Try different filter settings or clear all filters.
</p>
<div className="flex justify-center gap-2 mt-4">
<div className="mt-4 flex justify-center gap-2">
{filters.searchQuery && (
<Button
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
onClick={() =>
handleFiltersChange({ ...filters, searchQuery: "" })
}
variant="default"
size="default"
>
@@ -502,18 +658,17 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
</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) => {
) : viewMode === "card" ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
if (!script || typeof script !== 'object') {
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}`;
const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
return (
<ScriptCard
key={uniqueKey}
@@ -522,18 +677,18 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
/>
);
})}
</div>
) : (
<div className="space-y-3">
{filteredScripts.map((script, index) => {
</div>
) : (
<div className="space-y-3">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
if (!script || typeof script !== 'object') {
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}`;
const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
return (
<ScriptCardList
key={uniqueKey}
@@ -542,8 +697,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
/>
);
})}
</div>
)
</div>
)}
<ScriptDetailModal
@@ -552,6 +706,17 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
onClose={handleCloseModal}
onInstallScript={onInstallScript}
/>
<ConfirmationModal
isOpen={updateAllConfirmOpen}
onClose={() => setUpdateAllConfirmOpen(false)}
onConfirm={handleUpdateAllConfirm}
title="Update all downloaded scripts"
message={`Update all ${downloadedScripts.length} downloaded scripts? This may take several minutes.`}
variant="simple"
confirmButtonText="Update all"
cancelButtonText="Cancel"
/>
</div>
</div>
</div>

View File

@@ -2,26 +2,31 @@
import { useState, useEffect } from 'react';
import type { Server } from '../../types/server';
import type { Script } from '../../types/script';
import { Button } from './ui/button';
import { ColorCodedDropdown } from './ColorCodedDropdown';
import { SettingsModal } from './SettingsModal';
import { ConfigurationModal, type EnvVars } from './ConfigurationModal';
import { useRegisterModal } from './modal/ModalStackProvider';
interface ExecutionModeModalProps {
isOpen: boolean;
onClose: () => void;
onExecute: (mode: 'local' | 'ssh', server?: Server) => void;
onExecute: (mode: 'local' | 'ssh', server?: Server, envVars?: EnvVars) => void;
scriptName: string;
script?: Script | null;
}
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) {
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, script }: ExecutionModeModalProps) {
useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose });
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [configMode, setConfigMode] = useState<'default' | 'advanced'>('default');
useEffect(() => {
if (isOpen) {
@@ -64,19 +69,25 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
}
};
const handleExecute = () => {
const handleConfigModeSelect = (mode: 'default' | 'advanced') => {
if (!selectedServer) {
setError('Please select a server for SSH execution');
setError('Please select a server first');
return;
}
onExecute('ssh', selectedServer);
setConfigMode(mode);
setConfigModalOpen(true);
};
const handleConfigConfirm = (envVars: EnvVars) => {
if (!selectedServer) return;
setConfigModalOpen(false);
onExecute('ssh', selectedServer, envVars);
onClose();
};
const handleServerSelect = (server: Server | null) => {
setSelectedServer(server);
setError(null); // Clear error when server is selected
};
@@ -164,6 +175,31 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
</div>
</div>
{/* Configuration Mode Selection */}
<div className="space-y-3">
<p className="text-sm text-muted-foreground text-center">
Choose configuration mode:
</p>
<div className="flex gap-3">
<Button
onClick={() => handleConfigModeSelect('default')}
variant="default"
size="default"
className="flex-1"
>
Default
</Button>
<Button
onClick={() => handleConfigModeSelect('advanced')}
variant="outline"
size="default"
className="flex-1"
>
Advanced (Beta)
</Button>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<Button
@@ -173,13 +209,6 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
>
Cancel
</Button>
<Button
onClick={handleExecute}
variant="default"
size="default"
>
Install
</Button>
</div>
</div>
) : (
@@ -204,6 +233,33 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
/>
</div>
{/* Configuration Mode Selection - only show when server is selected */}
{selectedServer && (
<div className="space-y-3 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground text-center">
Choose configuration mode:
</p>
<div className="flex gap-3">
<Button
onClick={() => handleConfigModeSelect('default')}
variant="default"
size="default"
className="flex-1"
>
Default
</Button>
<Button
onClick={() => handleConfigModeSelect('advanced')}
variant="outline"
size="default"
className="flex-1"
>
Advanced
</Button>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<Button
@@ -213,15 +269,6 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
>
Cancel
</Button>
<Button
onClick={handleExecute}
disabled={!selectedServer}
variant="default"
size="default"
className={!selectedServer ? 'bg-muted-foreground cursor-not-allowed' : ''}
>
Run on Server
</Button>
</div>
</div>
)}
@@ -234,6 +281,16 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
isOpen={settingsModalOpen}
onClose={handleSettingsModalClose}
/>
{/* Configuration Modal */}
<ConfigurationModal
isOpen={configModalOpen}
onClose={() => setConfigModalOpen(false)}
onConfirm={handleConfigConfirm}
script={script ?? null}
server={selectedServer}
mode={configMode}
/>
</>
);
}

View File

@@ -3,7 +3,17 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter, GitBranch } from "lucide-react";
import {
Package,
Monitor,
Wrench,
Server,
FileText,
Calendar,
RefreshCw,
Filter,
GitBranch,
} from "lucide-react";
import { api } from "~/trpc/react";
import { getDefaultFilters } from "./filterUtils";
@@ -49,11 +59,11 @@ export function FilterBar({
// Fetch enabled repositories
const { data: enabledReposData } = api.repositories.getEnabled.useQuery();
const enabledRepos = enabledReposData?.repositories ?? [];
// Helper function to extract repository name from URL
const getRepoName = (url: string): string => {
try {
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) {
return `${match[1]}/${match[2]}`;
}
@@ -98,29 +108,33 @@ export function FilterBar({
};
return (
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
<div className="border-border bg-card mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
{/* 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>
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
<span>Loading saved filters...</span>
</div>
</div>
)}
{/* Filter Header */}
{!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
<h3 className="text-foreground text-lg font-medium">
Filter Scripts
</h3>
<div className="flex items-center gap-2">
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
<ContextualHelpIcon
section="available-scripts"
tooltip="Help with filtering and searching"
/>
<Button
onClick={() => setIsMinimized(!isMinimized)}
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground h-8 w-8"
title={isMinimized ? "Expand filters" : "Minimize filters"}
>
<svg
@@ -146,10 +160,10 @@ export function FilterBar({
<>
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="relative w-full max-w-md">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
className="text-muted-foreground h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -167,13 +181,13 @@ export function FilterBar({
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
className="border-input bg-background text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-primary block w-full rounded-lg border py-3 pr-10 pl-10 text-sm leading-5 focus:ring-2 focus:outline-none"
/>
{filters.searchQuery && (
<Button
onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost"
className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 flex h-full items-center justify-center pr-3"
>
<svg
className="h-5 w-5"
@@ -194,318 +208,335 @@ export function FilterBar({
</div>
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */}
<Button
onClick={() => {
const next =
filters.showUpdatable === null
? true
: filters.showUpdatable === true
? false
: null;
updateFilters({ showUpdatable: next });
}}
variant="outline"
size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true
? "border border-success/20 bg-success/10 text-success"
: "border border-destructive/20 bg-destructive/10 text-destructive"
}`}
>
<RefreshCw className="h-4 w-4" />
<span>{getUpdatableButtonText()}</span>
</Button>
{/* Type Dropdown */}
<div className="relative w-full sm:w-auto">
<Button
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
variant="outline"
size="default"
className={`w-full flex items-center justify-center space-x-2 ${
filters.selectedTypes.length === 0
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: "border border-primary/20 bg-primary/10 text-primary"
}`}
>
<Filter className="h-4 w-4" />
<span>{getTypeButtonText()}</span>
<svg
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{isTypeDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2">
{SCRIPT_TYPES.map((type) => {
const IconComponent = type.Icon;
return (
<label
key={type.value}
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
>
<input
type="checkbox"
checked={filters.selectedTypes.includes(type.value)}
onChange={(e) => {
if (e.target.checked) {
updateFilters({
selectedTypes: [
...filters.selectedTypes,
type.value,
],
});
} else {
updateFilters({
selectedTypes: filters.selectedTypes.filter(
(t) => t !== type.value,
),
});
}
}}
className="rounded border-input text-primary focus:ring-primary"
/>
<IconComponent className="h-4 w-4" />
<span className="text-sm text-muted-foreground">
{type.label}
</span>
</label>
);
})}
</div>
<div className="border-t border-border p-2">
<Button
onClick={() => {
updateFilters({ selectedTypes: [] });
setIsTypeDropdownOpen(false);
}}
variant="ghost"
size="sm"
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
>
Clear all
</Button>
</div>
</div>
)}
</div>
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
{enabledRepos.length > 1 && enabledRepos.map((repo) => {
const isSelected = filters.selectedRepositories.includes(repo.url);
return (
<div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3">
{/* Updateable Filter */}
<Button
key={repo.id}
onClick={() => {
const currentSelected = filters.selectedRepositories;
if (isSelected) {
// Remove repository from selection
updateFilters({
selectedRepositories: currentSelected.filter(url => url !== repo.url)
});
} else {
// Add repository to selection
updateFilters({
selectedRepositories: [...currentSelected, repo.url]
});
}
const next =
filters.showUpdatable === null
? true
: filters.showUpdatable === true
? false
: null;
updateFilters({ showUpdatable: next });
}}
variant="outline"
size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
isSelected
? "border border-primary/20 bg-primary/10 text-primary"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true
? "border-success/20 bg-success/10 text-success border"
: "border-destructive/20 bg-destructive/10 text-destructive border"
}`}
>
<GitBranch className="h-4 w-4" />
<span>{getRepoName(repo.url)}</span>
<RefreshCw className="h-4 w-4" />
<span>{getUpdatableButtonText()}</span>
</Button>
);
})}
{/* 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>
{/* Type Dropdown */}
<div className="relative w-full sm:w-auto">
<Button
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
variant="outline"
size="default"
className={`flex w-full items-center justify-center space-x-2 ${
filters.selectedTypes.length === 0
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: "border-primary/20 bg-primary/10 text-primary border"
}`}
>
<Filter className="h-4 w-4" />
<span>{getTypeButtonText()}</span>
<svg
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{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>
{isTypeDropdownOpen && (
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border shadow-lg">
<div className="p-2">
{SCRIPT_TYPES.map((type) => {
const IconComponent = type.Icon;
return (
<label
key={type.value}
className="hover:bg-accent flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2"
>
<input
type="checkbox"
checked={filters.selectedTypes.includes(type.value)}
onChange={(e) => {
if (e.target.checked) {
updateFilters({
selectedTypes: [
...filters.selectedTypes,
type.value,
],
});
} else {
updateFilters({
selectedTypes: filters.selectedTypes.filter(
(t) => t !== type.value,
),
});
}
}}
className="border-input text-primary focus:ring-primary rounded"
/>
<IconComponent className="h-4 w-4" />
<span className="text-muted-foreground text-sm">
{type.label}
</span>
</label>
);
})}
</div>
<div className="border-border border-t p-2">
<Button
onClick={() => {
updateFilters({ selectedTypes: [] });
setIsTypeDropdownOpen(false);
}}
variant="ghost"
size="sm"
className="text-muted-foreground hover:bg-accent hover:text-foreground w-full justify-start"
>
Clear all
</Button>
</div>
</div>
)}
</div>
)}
</div>
{/* Sort Order Button */}
<Button
onClick={() =>
updateFilters({
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
})
}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{filters.sortOrder === "asc" ? (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 11l5-5m0 0l5 5m-5-5v12"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 13l-5 5m0 0l-5-5m5 5V6"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
</span>
</>
)}
</Button>
</div>
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
{enabledRepos.length > 1 &&
enabledRepos.map((repo: { id: number; url: string }) => {
const repoUrl = String(repo.url);
const isSelected =
filters.selectedRepositories.includes(repoUrl);
return (
<Button
key={repo.id}
onClick={() => {
const currentSelected = filters.selectedRepositories;
if (isSelected) {
// Remove repository from selection
updateFilters({
selectedRepositories: currentSelected.filter(
(url) => url !== repoUrl,
),
});
} else {
// Add repository to selection
updateFilters({
selectedRepositories: [...currentSelected, repoUrl],
});
}
}}
variant="outline"
size="default"
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
isSelected
? "border-primary/20 bg-primary/10 text-primary border"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<GitBranch className="h-4 w-4" />
<span>{getRepoName(repoUrl)}</span>
</Button>
);
})}
{/* Filter Summary and Clear All */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="flex items-center gap-4">
<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-info">
(filtered)
{/* Sort By Dropdown */}
<div className="relative w-full sm:w-auto">
<Button
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
variant="outline"
size="default"
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-2 sm:w-auto"
>
{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="border-border bg-card absolute top-full left-0 z-10 mt-1 w-full rounded-lg border shadow-lg sm:w-48">
<div className="p-2">
<button
onClick={() => {
updateFilters({ sortBy: "name" });
setIsSortDropdownOpen(false);
}}
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
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={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
filters.sortBy === "created"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
}`}
>
<Calendar className="h-4 w-4" />
<span className="text-sm">By Created Date</span>
</button>
</div>
</div>
)}
</div>
{/* Sort Order Button */}
<Button
onClick={() =>
updateFilters({
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
})
}
variant="outline"
size="default"
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-1 sm:w-auto"
>
{filters.sortOrder === "asc" ? (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 11l5-5m0 0l5 5m-5-5v12"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 13l-5 5m0 0l-5-5m5 5V6"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
</span>
</>
)}
</Button>
</div>
{/* Filter Summary and Clear All */}
<div className="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
<div className="flex items-center gap-4">
<div className="text-muted-foreground text-sm">
{filteredCount === totalScripts ? (
<span>Showing all {totalScripts} scripts</span>
) : (
<span>
{filteredCount} of {totalScripts} scripts{" "}
{hasActiveFilters && (
<span className="text-info font-medium">(filtered)</span>
)}
</span>
)}
</span>
</div>
{/* Filter Persistence Status */}
{!isLoadingFilters && saveFiltersEnabled && (
<div className="text-success flex items-center space-x-1 text-xs">
<svg
className="h-3 w-3"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span>Filters are being saved automatically</span>
</div>
)}
</div>
{hasActiveFilters && (
<Button
onClick={clearAllFilters}
variant="ghost"
size="sm"
className="text-error hover:bg-error/10 hover:text-error-foreground flex w-full items-center justify-center space-x-1 sm:w-auto sm:justify-start"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span>Clear all filters</span>
</Button>
)}
</div>
{/* Filter Persistence Status */}
{!isLoadingFilters && saveFiltersEnabled && (
<div className="flex items-center space-x-1 text-xs text-success">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>Filters are being saved automatically</span>
</div>
)}
</div>
{hasActiveFilters && (
<Button
onClick={clearAllFilters}
variant="ghost"
size="sm"
className="flex items-center space-x-1 text-error hover:bg-error/10 hover:text-error-foreground w-full sm:w-auto justify-center sm:justify-start"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span>Clear all filters</span>
</Button>
)}
</div>
</>
)}

View File

@@ -16,7 +16,7 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) {
<div className="container mx-auto px-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>© 2024 PVE Scripts Local</span>
<span>© 2026 PVE Scripts Local</span>
{versionData?.success && versionData.version && (
<Button
variant="ghost"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, startTransition } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Input } from './ui/input';
@@ -159,9 +159,13 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L
useEffect(() => {
if (configData?.success) {
populateFormData(configData);
setHasChanges(false);
startTransition(() => {
setHasChanges(false);
});
} else if (configData && !configData.success) {
setError(String(configData.error ?? 'Failed to load configuration'));
startTransition(() => {
setError(String(configData.error ?? 'Failed to load configuration'));
});
}
}, [configData]);

View File

@@ -1,34 +1,45 @@
'use client';
"use client";
import { Loader2, CheckCircle, X } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useEffect, useRef } from 'react';
import { Button } from './ui/button';
import { Loader2, CheckCircle, X } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useEffect, useRef } from "react";
import { Button } from "./ui/button";
interface LoadingModalProps {
isOpen: boolean;
action: string;
action?: string;
logs?: string[];
isComplete?: boolean;
title?: string;
onClose?: () => void;
}
export function LoadingModal({ isOpen, action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) {
export function LoadingModal({
isOpen,
action,
logs = [],
isComplete = false,
title,
onClose,
}: LoadingModalProps) {
// Allow dismissing with ESC only when complete, prevent during running
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) });
useRegisterModal(isOpen, {
id: "loading-modal",
allowEscape: isComplete,
onClose: onClose ?? (() => null),
});
const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
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-2xl w-full border border-border p-8 max-h-[80vh] flex flex-col relative">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border relative flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border p-8 shadow-xl">
{/* Close button - only show when complete */}
{isComplete && onClose && (
<Button
@@ -40,31 +51,35 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
<X className="h-4 w-4" />
</Button>
)}
<div className="flex flex-col items-center space-y-4">
<div className="relative">
{isComplete ? (
<CheckCircle className="h-12 w-12 text-success" />
<CheckCircle className="text-success h-12 w-12" />
) : (
<>
<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>
<Loader2 className="text-primary h-12 w-12 animate-spin" />
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
</>
)}
</div>
{/* Static title text */}
{title && (
<p className="text-sm text-muted-foreground">
{title}
</p>
{/* Action text - displayed prominently */}
{action && (
<p className="text-foreground text-base font-medium">{action}</p>
)}
{/* Static title text */}
{title && <p className="text-muted-foreground text-sm">{title}</p>}
{/* Log output */}
{logs.length > 0 && (
<div className="w-full bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-[60vh] overflow-y-auto terminal-output">
<div className="bg-card border-border text-chart-2 terminal-output max-h-[60vh] w-full overflow-y-auto rounded-lg border p-4 font-mono text-xs">
{logs.map((log, index) => (
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
<div
key={index}
className="mb-1 break-words whitespace-pre-wrap"
>
{log}
</div>
))}
@@ -74,9 +89,15 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
{!isComplete && (
<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 className="bg-primary h-2 w-2 animate-bounce rounded-full"></div>
<div
className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.1s" }}
></div>
<div
className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.2s" }}
></div>
</div>
)}
</div>
@@ -84,4 +105,3 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
</div>
);
}

View File

@@ -1,11 +1,11 @@
'use client';
"use client";
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Lock, CheckCircle, AlertCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from '~/trpc/react';
import type { Storage } from '~/server/services/storageService';
import { useState, useEffect } from "react";
import { Button } from "./ui/button";
import { Lock, CheckCircle, AlertCircle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { api } from "~/trpc/react";
import type { Storage } from "~/server/services/storageService";
interface PBSCredentialsModalProps {
isOpen: boolean;
@@ -19,76 +19,84 @@ export function PBSCredentialsModal({
isOpen,
onClose,
serverId,
serverName,
storage
serverName: _serverName,
storage,
}: PBSCredentialsModalProps) {
const [pbsIp, setPbsIp] = useState('');
const [pbsDatastore, setPbsDatastore] = useState('');
const [pbsPassword, setPbsPassword] = useState('');
const [pbsFingerprint, setPbsFingerprint] = useState('');
const [pbsIp, setPbsIp] = useState("");
const [pbsDatastore, setPbsDatastore] = useState("");
const [pbsPassword, setPbsPassword] = useState("");
const [pbsFingerprint, setPbsFingerprint] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Extract PBS info from storage object
const pbsIpFromStorage = (storage as any).server || null;
const pbsDatastoreFromStorage = (storage as any).datastore || null;
const pbsIpFromStorage = (storage as { server?: string }).server ?? null;
const pbsDatastoreFromStorage =
(storage as { datastore?: string }).datastore ?? null;
// Fetch existing credentials
const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery(
{ serverId, storageName: storage.name },
{ enabled: isOpen }
);
const { data: credentialData, refetch } =
api.pbsCredentials.getCredentialsForStorage.useQuery(
{ serverId, storageName: storage.name },
{ enabled: isOpen },
);
// Initialize form with storage config values or existing credentials
useEffect(() => {
if (isOpen) {
if (credentialData?.success && credentialData.credential) {
// Load existing credentials
setPbsIp(credentialData.credential.pbs_ip);
setPbsDatastore(credentialData.credential.pbs_datastore);
setPbsPassword(''); // Don't show password
setPbsFingerprint(credentialData.credential.pbs_fingerprint || '');
setPbsIp(String(credentialData.credential.pbs_ip));
setPbsDatastore(String(credentialData.credential.pbs_datastore));
setPbsPassword(""); // Don't show password
setPbsFingerprint(
String(credentialData.credential.pbs_fingerprint ?? ""),
);
} else {
// Initialize with storage config values
setPbsIp(pbsIpFromStorage || '');
setPbsDatastore(pbsDatastoreFromStorage || '');
setPbsPassword('');
setPbsFingerprint('');
setPbsIp(pbsIpFromStorage ?? "");
setPbsDatastore(pbsDatastoreFromStorage ?? "");
setPbsPassword("");
setPbsFingerprint("");
}
}
}, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]);
const saveCredentials = api.pbsCredentials.saveCredentials.useMutation({
onSuccess: () => {
void refetch();
onClose();
},
onError: (error) => {
console.error('Failed to save PBS credentials:', error);
console.error("Failed to save PBS credentials:", error);
alert(`Failed to save credentials: ${error.message}`);
},
});
const deleteCredentials = api.pbsCredentials.deleteCredentials.useMutation({
onSuccess: () => {
void refetch();
onClose();
},
onError: (error) => {
console.error('Failed to delete PBS credentials:', error);
console.error("Failed to delete PBS credentials:", error);
alert(`Failed to delete credentials: ${error.message}`);
},
});
useRegisterModal(isOpen, { id: 'pbs-credentials-modal', allowEscape: true, onClose });
useRegisterModal(isOpen, {
id: "pbs-credentials-modal",
allowEscape: true,
onClose,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!pbsIp || !pbsDatastore || !pbsFingerprint) {
alert('Please fill in all required fields (IP, Datastore, Fingerprint)');
alert("Please fill in all required fields (IP, Datastore, Fingerprint)");
return;
}
// Password is optional when updating existing credentials
setIsLoading(true);
try {
@@ -104,12 +112,16 @@ export function PBSCredentialsModal({
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete the PBS credentials for this storage?')) {
if (
!confirm(
"Are you sure you want to delete the PBS credentials for this storage?",
)
) {
return;
}
setIsLoading(true);
try {
await deleteCredentials.mutateAsync({
@@ -120,19 +132,19 @@ export function PBSCredentialsModal({
setIsLoading(false);
}
};
if (!isOpen) return null;
const hasCredentials = credentialData?.success && credentialData.credential;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center gap-3">
<Lock className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">
<Lock className="text-primary h-6 w-6" />
<h2 className="text-card-foreground text-2xl font-bold">
PBS Credentials - {storage.name}
</h2>
</div>
@@ -142,18 +154,31 @@ export function PBSCredentialsModal({
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Storage Name (read-only) */}
<div>
<label htmlFor="storage-name" className="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="storage-name"
className="text-foreground mb-1 block text-sm font-medium"
>
Storage Name
</label>
<input
@@ -161,13 +186,16 @@ export function PBSCredentialsModal({
id="storage-name"
value={storage.name}
disabled
className="w-full px-3 py-2 border rounded-md shadow-sm bg-muted text-muted-foreground border-border cursor-not-allowed"
className="bg-muted text-muted-foreground border-border w-full cursor-not-allowed rounded-md border px-3 py-2 shadow-sm"
/>
</div>
{/* PBS IP */}
<div>
<label htmlFor="pbs-ip" className="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="pbs-ip"
className="text-foreground mb-1 block text-sm font-medium"
>
PBS Server IP <span className="text-error">*</span>
</label>
<input
@@ -177,17 +205,20 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsIp(e.target.value)}
required
disabled={isLoading}
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"
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="e.g., 10.10.10.226"
/>
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-1 text-xs">
IP address of the Proxmox Backup Server
</p>
</div>
{/* PBS Datastore */}
<div>
<label htmlFor="pbs-datastore" className="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="pbs-datastore"
className="text-foreground mb-1 block text-sm font-medium"
>
PBS Datastore <span className="text-error">*</span>
</label>
<input
@@ -197,75 +228,87 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsDatastore(e.target.value)}
required
disabled={isLoading}
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"
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="e.g., NAS03-ISCSI-BACKUP"
/>
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-1 text-xs">
Name of the datastore on the PBS server
</p>
</div>
{/* PBS Password */}
<div>
<label htmlFor="pbs-password" className="block text-sm font-medium text-foreground mb-1">
Password {!hasCredentials && <span className="text-error">*</span>}
<label
htmlFor="pbs-password"
className="text-foreground mb-1 block text-sm font-medium"
>
Password{" "}
{!hasCredentials && <span className="text-error">*</span>}
</label>
<input
<input
type="password"
id="pbs-password"
value={pbsPassword}
onChange={(e) => setPbsPassword(e.target.value)}
required={!hasCredentials}
disabled={isLoading}
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={hasCredentials ? "Enter new password (leave empty to keep existing)" : "Enter PBS password"}
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder={
hasCredentials
? "Enter new password (leave empty to keep existing)"
: "Enter PBS password"
}
/>
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-1 text-xs">
Password for root@pam user on PBS server
</p>
</div>
{/* PBS Fingerprint */}
<div>
<label htmlFor="pbs-fingerprint" className="block text-sm font-medium text-foreground mb-1">
Fingerprint <span className="text-error">*</span>
<label
htmlFor="pbs-fingerprint"
className="text-foreground mb-1 block text-sm font-medium"
>
Fingerprint
</label>
<input
type="text"
id="pbs-fingerprint"
value={pbsFingerprint}
onChange={(e) => setPbsFingerprint(e.target.value)}
required
disabled={isLoading}
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"
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02"
/>
<p className="mt-1 text-xs text-muted-foreground">
Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button.
<p className="text-muted-foreground mt-1 text-xs">
Leave empty if PBS uses a trusted CA (e.g. Let&apos;s Encrypt).
For self-signed certificates, enter the server fingerprint from
the PBS dashboard (&quot;Show Fingerprint&quot;).
</p>
</div>
{/* Status indicator */}
{hasCredentials && (
<div className="p-3 bg-success/10 border border-success/20 rounded-lg flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-success" />
<span className="text-sm text-success font-medium">
<div className="bg-success/10 border-success/20 flex items-center gap-2 rounded-lg border p-3">
<CheckCircle className="text-success h-4 w-4" />
<span className="text-success text-sm font-medium">
Credentials are configured for this storage
</span>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4">
<div className="flex flex-col justify-end gap-3 pt-4 sm:flex-row">
{hasCredentials && (
<Button
type="button"
onClick={handleDelete}
variant="outline"
disabled={isLoading}
className="w-full sm:w-auto order-3"
className="order-3 w-full sm:w-auto"
>
<AlertCircle className="h-4 w-4 mr-2" />
<AlertCircle className="mr-2 h-4 w-4" />
Delete Credentials
</Button>
)}
@@ -274,7 +317,7 @@ export function PBSCredentialsModal({
onClick={onClose}
variant="outline"
disabled={isLoading}
className="w-full sm:w-auto order-2"
className="order-2 w-full sm:w-auto"
>
Cancel
</Button>
@@ -282,9 +325,13 @@ export function PBSCredentialsModal({
type="submit"
variant="default"
disabled={isLoading}
className="w-full sm:w-auto order-1"
className="order-1 w-full sm:w-auto"
>
{isLoading ? 'Saving...' : hasCredentials ? 'Update Credentials' : 'Save Credentials'}
{isLoading
? "Saving..."
: hasCredentials
? "Update Credentials"
: "Save Credentials"}
</Button>
</div>
</form>
@@ -293,4 +340,3 @@ export function PBSCredentialsModal({
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, startTransition } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
@@ -47,7 +47,9 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release
// Get current version when modal opens
useEffect(() => {
if (isOpen && versionData?.success && versionData.version) {
setCurrentVersion(versionData.version);
startTransition(() => {
setCurrentVersion(versionData.version);
});
}
}, [isOpen, versionData]);

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { ContextualHelpIcon } from './ContextualHelpIcon';
@@ -9,6 +9,10 @@ export function ResyncButton() {
const [isResyncing, setIsResyncing] = useState(false);
const [lastSync, setLastSync] = useState<Date | null>(null);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const hasReloadedRef = useRef<boolean>(false);
const isUserInitiatedRef = useRef<boolean>(false);
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const resyncMutation = api.scripts.resyncScripts.useMutation({
onSuccess: (data) => {
@@ -16,29 +20,87 @@ export function ResyncButton() {
setLastSync(new Date());
if (data.success) {
setSyncMessage(data.message ?? 'Scripts synced successfully');
// Reload the page after successful sync
setTimeout(() => {
window.location.reload();
}, 2000); // Wait 2 seconds to show the success message
// Only reload if this was triggered by user action
if (isUserInitiatedRef.current && !hasReloadedRef.current) {
hasReloadedRef.current = true;
// Clear any existing reload timeout
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Set new reload timeout
reloadTimeoutRef.current = setTimeout(() => {
reloadTimeoutRef.current = null;
window.location.reload();
}, 2000); // Wait 2 seconds to show the success message
} else {
// Reset flag if reload didn't happen
isUserInitiatedRef.current = false;
}
} else {
setSyncMessage(data.error ?? 'Failed to sync scripts');
// Clear message after 3 seconds for errors
setTimeout(() => setSyncMessage(null), 3000);
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
messageTimeoutRef.current = setTimeout(() => {
setSyncMessage(null);
messageTimeoutRef.current = null;
}, 3000);
isUserInitiatedRef.current = false;
}
},
onError: (error) => {
setIsResyncing(false);
setSyncMessage(`Error: ${error.message}`);
setTimeout(() => setSyncMessage(null), 3000);
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
messageTimeoutRef.current = setTimeout(() => {
setSyncMessage(null);
messageTimeoutRef.current = null;
}, 3000);
isUserInitiatedRef.current = false;
},
});
const handleResync = async () => {
// Prevent multiple simultaneous sync operations
if (isResyncing) return;
// Clear any pending reload timeout
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Mark as user-initiated before starting
isUserInitiatedRef.current = true;
hasReloadedRef.current = false;
setIsResyncing(true);
setSyncMessage(null);
resyncMutation.mutate();
};
// Cleanup on unmount - clear any pending timeouts
useEffect(() => {
return () => {
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
messageTimeoutRef.current = null;
}
// Reset refs on unmount
hasReloadedRef.current = false;
isUserInitiatedRef.current = false;
};
}, []);
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">

View File

@@ -1,9 +1,9 @@
'use client';
"use client";
import { useState } from 'react';
import Image from 'next/image';
import type { ScriptCard } from '~/types/script';
import { TypeBadge, UpdateableBadge } from './Badge';
import { useState } from "react";
import Image from "next/image";
import type { ScriptCard } from "~/types/script";
import { TypeBadge, UpdateableBadge } from "./Badge";
interface ScriptCardProps {
script: ScriptCard;
@@ -12,7 +12,12 @@ interface ScriptCardProps {
onToggleSelect?: (slug: string) => void;
}
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
export function ScriptCard({
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardProps) {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
@@ -27,8 +32,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
};
const getRepoName = (url?: string): string => {
if (!url) return '';
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (!url) return "";
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) {
return `${match[1]}/${match[2]}`;
}
@@ -37,32 +42,36 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
return (
<div
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
className="bg-card border-border hover:border-primary relative flex h-full cursor-pointer flex-col rounded-lg border shadow-md transition-shadow duration-200 hover:shadow-lg"
onClick={() => onClick(script)}
>
{/* Checkbox in top-left corner */}
{onToggleSelect && (
<div className="absolute top-2 left-2 z-10">
<div
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
<div
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
}`}
onClick={handleCheckboxClick}
>
{isSelected && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
<svg 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>
)}
</div>
</div>
)}
<div className="p-6 flex-1 flex flex-col">
<div className="flex flex-1 flex-col p-6">
{/* Header with logo and name */}
<div className="flex items-start space-x-4 mb-4">
<div className="mb-4 flex items-start space-x-4">
<div className="flex-shrink-0">
{script.logo && !imageError ? (
<Image
@@ -70,42 +79,49 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
alt={`${script.name} logo`}
width={48}
height={48}
className="w-12 h-12 rounded-lg object-contain"
className="h-12 w-12 rounded-lg object-contain"
onError={handleImageError}
/>
) : (
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
<span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'}
{script.name?.charAt(0)?.toUpperCase() || "?"}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground truncate">
{script.name || 'Unnamed Script'}
<div className="min-w-0 flex-1">
<h3 className="text-foreground truncate text-lg font-semibold">
{script.name || "Unnamed Script"}
</h3>
<div className="mt-2 space-y-2">
{/* Type and Updateable status on first row */}
<div className="flex items-center space-x-2 flex-wrap gap-1">
<TypeBadge type={script.type ?? 'unknown'} />
<div className="flex flex-wrap items-center gap-1 space-x-2">
<TypeBadge type={script.type ?? "unknown"} />
{script.updateable && <UpdateableBadge />}
{script.repository_url && (
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
<span
className="bg-muted text-muted-foreground border-border rounded border px-2 py-0.5 text-xs"
title={script.repository_url}
>
{getRepoName(script.repository_url)}
</span>
)}
</div>
{/* Download Status */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
script.isDownloaded ? 'bg-success' : 'bg-error'
}`}></div>
<span className={`text-xs font-medium ${
script.isDownloaded ? 'text-success' : 'text-error'
}`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
<div
className={`h-2 w-2 rounded-full ${
script.isDownloaded ? "bg-success" : "bg-error"
}`}
></div>
<span
className={`text-xs font-medium ${
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
</span>
</div>
</div>
@@ -113,8 +129,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
</div>
{/* Description */}
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
{script.description || 'No description available'}
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
{script.description || "No description available"}
</p>
{/* Footer with website link */}
@@ -124,12 +140,22 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1"
className="text-info hover:text-info/80 flex items-center space-x-1 text-sm font-medium"
onClick={(e) => e.stopPropagation()}
>
<span>Website</span>
<svg className="w-3 h-3" 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
className="h-3 w-3"
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>

View File

@@ -1,9 +1,9 @@
'use client';
"use client";
import { useState } from 'react';
import Image from 'next/image';
import type { ScriptCard } from '~/types/script';
import { TypeBadge, UpdateableBadge } from './Badge';
import { useState } from "react";
import Image from "next/image";
import type { ScriptCard } from "~/types/script";
import { TypeBadge, UpdateableBadge } from "./Badge";
interface ScriptCardListProps {
script: ScriptCard;
@@ -12,7 +12,12 @@ interface ScriptCardListProps {
onToggleSelect?: (slug: string) => void;
}
export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) {
export function ScriptCardList({
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardListProps) {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
@@ -27,26 +32,27 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
};
const formatDate = (dateString?: string) => {
if (!dateString) return 'Unknown';
if (!dateString) return "Unknown";
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return 'Unknown';
return "Unknown";
}
};
const getCategoryNames = () => {
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
return script.categoryNames.join(', ');
if (!script.categoryNames || script.categoryNames.length === 0)
return "Uncategorized";
return script.categoryNames.join(", ");
};
const getRepoName = (url?: string): string => {
if (!url) return '';
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (!url) return "";
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
if (match) {
return `${match[1]}/${match[2]}`;
}
@@ -55,30 +61,34 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
return (
<div
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative"
className="bg-card border-border hover:border-primary relative cursor-pointer rounded-lg border shadow-sm transition-shadow duration-200 hover:shadow-md"
onClick={() => onClick(script)}
>
{/* Checkbox */}
{onToggleSelect && (
<div className="absolute top-4 left-4 z-10">
<div
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
<div
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
}`}
onClick={handleCheckboxClick}
>
{isSelected && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
<svg 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>
)}
</div>
</div>
)}
<div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}>
<div className={`p-6 ${onToggleSelect ? "pl-12" : ""}`}>
<div className="flex items-start space-x-4">
{/* Logo */}
<div className="flex-shrink-0">
@@ -88,42 +98,49 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
alt={`${script.name} logo`}
width={56}
height={56}
className="w-14 h-14 rounded-lg object-contain"
className="h-14 w-14 rounded-lg object-contain"
onError={handleImageError}
/>
) : (
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-lg">
<span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'}
{script.name?.charAt(0)?.toUpperCase() || "?"}
</span>
</div>
)}
</div>
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
{/* 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'}
<div className="mb-3 flex items-start justify-between">
<div className="min-w-0 flex-1">
<h3 className="text-foreground mb-2 truncate text-xl font-semibold">
{script.name || "Unnamed Script"}
</h3>
<div className="flex items-center space-x-3 flex-wrap gap-2">
<TypeBadge type={script.type ?? 'unknown'} />
<div className="flex flex-wrap items-center gap-2 space-x-3">
<TypeBadge type={script.type ?? "unknown"} />
{script.updateable && <UpdateableBadge />}
{script.repository_url && (
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
<span
className="bg-muted text-muted-foreground border-border rounded border px-2 py-0.5 text-xs"
title={script.repository_url}
>
{getRepoName(script.repository_url)}
</span>
)}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
script.isDownloaded ? 'bg-success' : 'bg-error'
}`}></div>
<span className={`text-sm font-medium ${
script.isDownloaded ? 'text-success' : 'text-error'
}`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
<div
className={`h-2 w-2 rounded-full ${
script.isDownloaded ? "bg-success" : "bg-error"
}`}
></div>
<span
className={`text-sm font-medium ${
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
</span>
</div>
</div>
@@ -135,68 +152,128 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1 ml-4"
className="text-info hover:text-info/80 ml-4 flex items-center space-x-1 text-sm font-medium"
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
className="h-4 w-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 className="text-muted-foreground mb-4 line-clamp-2 text-sm">
{script.description || "No description available"}
</p>
{/* Metadata Row */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="text-muted-foreground flex items-center justify-between text-xs">
<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
className="h-3 w-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
className="h-3 w-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
className="h-3 w-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 && 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.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
className="h-3 w-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
className="h-3 w-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>
<span>ID: {script.slug || "unknown"}</span>
</div>
</div>
</div>

View File

@@ -4,14 +4,20 @@ import { useState } from "react";
import Image from "next/image";
import { api } from "~/trpc/react";
import type { Script } from "~/types/script";
import type { Server } from "~/types/server";
import { DiffViewer } from "./DiffViewer";
import { TextViewer } from "./TextViewer";
import { ExecutionModeModal } from "./ExecutionModeModal";
import { ConfirmationModal } from "./ConfirmationModal";
import { ScriptVersionModal } from "./ScriptVersionModal";
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
import {
TypeBadge,
UpdateableBadge,
PrivilegedBadge,
NoteBadge,
} from "./Badge";
import { Button } from "./ui/button";
import { useRegisterModal } from './modal/ModalStackProvider';
import { useRegisterModal } from "./modal/ModalStackProvider";
interface ScriptDetailModalProps {
script: Script | null;
@@ -21,7 +27,8 @@ interface ScriptDetailModalProps {
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: any,
server?: Server,
envVars?: Record<string, string | number | boolean>,
) => void;
}
@@ -31,7 +38,11 @@ export function ScriptDetailModal({
onClose,
onInstallScript,
}: ScriptDetailModalProps) {
useRegisterModal(isOpen, { id: 'script-detail-modal', allowEscape: true, onClose });
useRegisterModal(isOpen, {
id: "script-detail-modal",
allowEscape: true,
onClose,
});
const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loadMessage, setLoadMessage] = useState<string | null>(null);
@@ -40,7 +51,9 @@ export function ScriptDetailModal({
const [textViewerOpen, setTextViewerOpen] = useState(false);
const [executionModeOpen, setExecutionModeOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false);
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(
null,
);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -61,7 +74,11 @@ export function ScriptDetailModal({
isLoading: comparisonLoading,
} = api.scripts.compareScriptContent.useQuery(
{ slug: script?.slug ?? "" },
{ enabled: !!script && isOpen },
{
enabled: !!script && isOpen,
refetchOnMount: true,
staleTime: 0,
},
);
// Load script mutation
@@ -136,22 +153,27 @@ export function ScriptDetailModal({
const handleInstallScript = () => {
if (!script) return;
// Check if script has multiple variants (default and alpine)
const installMethods = script.install_methods || [];
const hasMultipleVariants = installMethods.filter(method =>
method.type === 'default' || method.type === 'alpine'
).length > 1;
const hasMultipleVariants =
installMethods.filter(
(method) => method.type === "default" || method.type === "alpine",
).length > 1;
if (hasMultipleVariants) {
// Show version selection modal first
setVersionModalOpen(true);
} else {
// Only one variant, proceed directly to execution mode
// Use the first available method or default to 'default' type
const defaultMethod = installMethods.find(method => method.type === 'default');
const defaultMethod = installMethods.find(
(method) => method.type === "default",
);
const firstMethod = installMethods[0];
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
setSelectedVersionType(
defaultMethod?.type ?? firstMethod?.type ?? "default",
);
setExecutionModeOpen(true);
}
};
@@ -162,23 +184,22 @@ export function ScriptDetailModal({
setExecutionModeOpen(true);
};
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
const handleExecuteScript = (mode: "local" | "ssh", server?: Server, envVars?: Record<string, string | number | boolean>) => {
if (!script || !onInstallScript) return;
// Find the script path based on selected version type
const versionType = selectedVersionType || 'default';
const scriptMethod = script.install_methods?.find(
(method) => method.type === versionType && method.script,
) || script.install_methods?.find(
(method) => method.script,
);
const versionType = selectedVersionType ?? "default";
const scriptMethod =
script.install_methods?.find(
(method) => method.type === versionType && method.script,
) ?? script.install_methods?.find((method) => method.script);
if (scriptMethod?.script) {
const scriptPath = `scripts/${scriptMethod.script}`;
const scriptName = script.name;
// Pass execution mode and server info to the parent
onInstallScript(scriptPath, scriptName, mode, server);
// Pass execution mode, server info, and envVars to the parent
onInstallScript(scriptPath, scriptName, mode, server, envVars);
onClose(); // Close the modal when starting installation
}
@@ -203,31 +224,31 @@ export function ScriptDetailModal({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onClick={handleBackdropClick}
>
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
<div className="bg-card border-border mx-2 max-h-[95vh] min-h-[80vh] w-full max-w-6xl overflow-y-auto rounded-lg border shadow-xl sm:mx-4 lg:mx-0">
{/* Header */}
<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">
<div className="border-border flex items-center justify-between border-b p-4 sm:p-6">
<div className="flex min-w-0 flex-1 items-center space-x-3 sm:space-x-4">
{script.logo && !imageError ? (
<Image
src={script.logo}
alt={`${script.name} logo`}
width={64}
height={64}
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
className="h-12 w-12 flex-shrink-0 rounded-lg object-contain sm:h-16 sm:w-16"
onError={handleImageError}
/>
) : (
<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">
<div className="bg-muted flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg sm:h-16 sm:w-16">
<span className="text-muted-foreground text-lg font-semibold sm:text-2xl">
{script.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
<h2 className="text-foreground truncate text-xl font-bold sm:text-2xl">
{script.name}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
@@ -239,37 +260,39 @@ export function ScriptDetailModal({
href={script.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border hover:bg-accent hover:text-foreground transition-colors"
className="bg-muted text-muted-foreground border-border hover:bg-accent hover:text-foreground rounded border px-2 py-0.5 text-xs transition-colors"
onClick={(e) => e.stopPropagation()}
title={`Source: ${script.repository_url}`}
>
{script.repository_url.match(/github\.com\/([^\/]+)\/([^\/]+)/)?.[0]?.replace('https://', '') ?? script.repository_url}
{/github\.com\/([^\/]+)\/([^\/]+)/
.exec(script.repository_url)?.[0]
?.replace("https://", "") ?? script.repository_url}
</a>
)}
</div>
</div>
{/* Interface Port*/}
{script.interface_port && (
<div className="ml-3 sm:ml-4 flex-shrink-0">
<div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2">
<span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2">
<div className="ml-3 flex-shrink-0 sm:ml-4">
<div className="bg-primary/10 border-primary/30 rounded-lg border px-3 py-1.5 sm:px-4 sm:py-2">
<span className="text-muted-foreground mr-2 text-xs font-medium sm:text-sm">
Port:
</span>
<span className="text-sm sm:text-base font-semibold text-foreground font-mono">
<span className="text-foreground font-mono text-sm font-semibold sm:text-base">
{script.interface_port}
</span>
</div>
</div>
)}
</div>
{/* Close Button */}
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
className="text-muted-foreground hover:text-foreground ml-4 flex-shrink-0"
>
<svg
className="h-5 w-5 sm:h-6 sm:w-6"
@@ -288,189 +311,91 @@ export function ScriptDetailModal({
</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
onClick={handleInstallScript}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
<div className="border-border flex flex-col items-stretch space-y-2 border-b p-4 sm:flex-row sm:items-center sm:space-y-0 sm:space-x-2 sm:p-6">
{/* Install Button - only show if script files exist */}
{scriptFilesData?.success &&
scriptFilesData.ctExists &&
onInstallScript && (
<Button
onClick={handleInstallScript}
variant="outline"
size="default"
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
<span>Install</span>
</Button>
)}
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
<span>Install</span>
</Button>
)}
{/* View Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<Button
onClick={handleViewScript}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
{/* View Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<Button
onClick={handleViewScript}
variant="outline"
size="default"
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span>View</span>
</Button>
)}
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span>View</span>
</Button>
)}
{/* Load/Update Script Button */}
{(() => {
const hasLocalFiles =
scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists);
const hasDifferences =
comparisonData?.success && comparisonData.hasDifferences;
const isUpToDate = hasLocalFiles && !hasDifferences;
{/* Load/Update Script Button */}
{(() => {
const hasLocalFiles =
scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists);
const hasDifferences =
comparisonData?.success && comparisonData.hasDifferences;
const isUpToDate = hasLocalFiles && !hasDifferences;
if (!hasLocalFiles) {
// No local files - show Load Script button
return (
<button
onClick={handleLoadScript}
disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-success text-success-foreground hover:bg-success/90"
}`}
>
{isLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Loading...</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span>Load Script</span>
</>
)}
</button>
);
} else if (isUpToDate) {
// Local files exist and are up to date - show disabled Update button
return (
<button
disabled
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"
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>Up to Date</span>
</button>
);
} else {
// Local files exist but have differences - show Update button
return (
<button
onClick={handleLoadScript}
disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-warning text-warning-foreground hover:bg-warning/90"
}`}
>
{isLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Updating...</span>
</>
) : (
<>
<svg
className="h-4 w-4"
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>Update Script</span>
</>
)}
</button>
);
}
})()}
{/* Delete Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<Button
onClick={handleDeleteScript}
disabled={isDeleting}
variant="destructive"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
if (!hasLocalFiles) {
// No local files - show Load Script button
return (
<button
onClick={handleLoadScript}
disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? "bg-muted text-muted-foreground cursor-not-allowed"
: "bg-success text-success-foreground hover:bg-success/90"
}`}
>
{isDeleting ? (
{isLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Deleting...</span>
<span>Loading...</span>
</>
) : (
<>
@@ -484,23 +409,121 @@ export function ScriptDetailModal({
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"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span>Delete Script</span>
<span>Load Script</span>
</>
)}
</Button>
)}
</button>
);
} else if (isUpToDate) {
// Local files exist and are up to date - show disabled Update button
return (
<button
disabled
className="bg-muted text-muted-foreground flex cursor-not-allowed items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors"
>
<svg
className="h-4 w-4"
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>Up to Date</span>
</button>
);
} else {
// Local files exist but have differences - show Update button
return (
<button
onClick={handleLoadScript}
disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading
? "bg-muted text-muted-foreground cursor-not-allowed"
: "bg-warning text-warning-foreground hover:bg-warning/90"
}`}
>
{isLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Updating...</span>
</>
) : (
<>
<svg
className="h-4 w-4"
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>Update Script</span>
</>
)}
</button>
);
}
})()}
{/* Delete Button - only show if script files exist */}
{scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
<Button
onClick={handleDeleteScript}
disabled={isDeleting}
variant="destructive"
size="default"
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
>
{isDeleting ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Deleting...</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span>Delete Script</span>
</>
)}
</Button>
)}
</div>
{/* Content */}
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="space-y-4 p-4 sm:space-y-6 sm:p-6">
{/* Script Files Status */}
{(scriptFilesLoading || comparisonLoading) && (
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
<div className="border-primary h-4 w-4 animate-spin rounded-full border-b-2"></div>
<span>Loading script status...</span>
</div>
</div>
@@ -523,8 +546,8 @@ export function ScriptDetailModal({
}
return (
<div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div className="bg-muted text-muted-foreground mb-4 rounded-lg p-3 text-sm">
<div className="flex flex-col space-y-2 sm:flex-row sm:items-center 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-success" : "bg-muted"}`}
@@ -547,24 +570,67 @@ export function ScriptDetailModal({
</div>
{scriptFilesData?.success &&
(scriptFilesData.ctExists ||
scriptFilesData.installExists) &&
comparisonData?.success &&
!comparisonLoading && (
scriptFilesData.installExists) && (
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
></div>
<span>
Status:{" "}
{comparisonData.hasDifferences
? "Update available"
: "Up to date"}
</span>
{comparisonData?.success ? (
<>
<div
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
></div>
<span>
Status:{" "}
{comparisonData.hasDifferences
? "Update available"
: "Up to date"}
</span>
</>
) : comparisonLoading ? (
<>
<div className="bg-muted h-2 w-2 animate-pulse rounded-full"></div>
<span>Checking for updates...</span>
</>
) : comparisonData?.error ? (
<>
<div className="bg-destructive h-2 w-2 rounded-full"></div>
<span className="text-destructive">
Error: {comparisonData.error}
</span>
</>
) : (
<>
<div className="bg-muted h-2 w-2 rounded-full"></div>
<span>Status: Unknown</span>
</>
)}
<button
onClick={() => void refetchComparison()}
disabled={comparisonLoading}
className="hover:bg-accent ml-2 flex items-center justify-center rounded-md p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
title="Refresh comparison"
>
{comparisonLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
) : (
<svg
className="text-muted-foreground hover:text-foreground h-4 w-4"
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>
)}
</button>
</div>
)}
</div>
{scriptFilesData.files.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground break-words">
<div className="text-muted-foreground mt-2 text-xs break-words">
Files: {scriptFilesData.files.join(", ")}
</div>
)}
@@ -574,17 +640,17 @@ export function ScriptDetailModal({
{/* Load Message */}
{loadMessage && (
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm">
{loadMessage}
</div>
)}
{/* Description */}
<div>
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-2 text-base font-semibold sm:text-lg">
Description
</h3>
<p className="text-sm sm:text-base text-muted-foreground">
<p className="text-muted-foreground text-sm sm:text-base">
{script.description}
</p>
</div>
@@ -592,50 +658,50 @@ export function ScriptDetailModal({
{/* Basic Information */}
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Basic Information
</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Slug
</dt>
<dd className="font-mono text-sm text-foreground">
<dd className="text-foreground font-mono text-sm">
{script.slug}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Date Created
</dt>
<dd className="text-sm text-foreground">
<dd className="text-foreground text-sm">
{script.date_created}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Categories
</dt>
<dd className="text-sm text-foreground">
<dd className="text-foreground text-sm">
{script.categories.join(", ")}
</dd>
</div>
{script.interface_port && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Interface Port
</dt>
<dd className="text-sm text-foreground">
<dd className="text-foreground text-sm">
{script.interface_port}
</dd>
</div>
)}
{script.config_path && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Config Path
</dt>
<dd className="font-mono text-sm text-foreground">
<dd className="text-foreground font-mono text-sm">
{script.config_path}
</dd>
</div>
@@ -644,13 +710,13 @@ export function ScriptDetailModal({
</div>
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Links
</h3>
<dl className="space-y-2">
{script.website && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Website
</dt>
<dd className="text-sm">
@@ -658,7 +724,7 @@ export function ScriptDetailModal({
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="break-all text-primary hover:text-primary/80"
className="text-primary hover:text-primary/80 break-all"
>
{script.website}
</a>
@@ -667,7 +733,7 @@ export function ScriptDetailModal({
)}
{script.documentation && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Documentation
</dt>
<dd className="text-sm">
@@ -675,7 +741,7 @@ export function ScriptDetailModal({
href={script.documentation}
target="_blank"
rel="noopener noreferrer"
className="break-all text-primary hover:text-primary/80"
className="text-primary hover:text-primary/80 break-all"
>
{script.documentation}
</a>
@@ -691,26 +757,26 @@ export function ScriptDetailModal({
script.type !== "pve" &&
script.type !== "addon" && (
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Install Methods
</h3>
<div className="space-y-4">
{script.install_methods.map((method, index) => (
<div
key={index}
className="rounded-lg border border-border bg-card p-3 sm:p-4"
className="border-border bg-card rounded-lg border p-3 sm:p-4"
>
<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">
<div className="mb-3 flex flex-col justify-between space-y-1 sm:flex-row sm:items-center sm:space-y-0">
<h4 className="text-foreground text-sm font-medium capitalize sm:text-base">
{method.type}
</h4>
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
<span className="text-muted-foreground font-mono text-xs break-all sm:text-sm">
{method.script}
</span>
</div>
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
<div className="grid grid-cols-2 gap-2 text-xs sm:gap-4 sm:text-sm lg:grid-cols-4">
<div>
<dt className="font-medium text-muted-foreground">
<dt className="text-muted-foreground font-medium">
CPU
</dt>
<dd className="text-foreground">
@@ -718,7 +784,7 @@ export function ScriptDetailModal({
</dd>
</div>
<div>
<dt className="font-medium text-muted-foreground">
<dt className="text-muted-foreground font-medium">
RAM
</dt>
<dd className="text-foreground">
@@ -726,7 +792,7 @@ export function ScriptDetailModal({
</dd>
</div>
<div>
<dt className="font-medium text-muted-foreground">
<dt className="text-muted-foreground font-medium">
HDD
</dt>
<dd className="text-foreground">
@@ -734,7 +800,7 @@ export function ScriptDetailModal({
</dd>
</div>
<div>
<dt className="font-medium text-muted-foreground">
<dt className="text-muted-foreground font-medium">
OS
</dt>
<dd className="text-foreground">
@@ -752,26 +818,26 @@ export function ScriptDetailModal({
{(script.default_credentials.username ??
script.default_credentials.password) && (
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
Default Credentials
</h3>
<dl className="space-y-2">
{script.default_credentials.username && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Username
</dt>
<dd className="font-mono text-sm text-foreground">
<dd className="text-foreground font-mono text-sm">
{script.default_credentials.username}
</dd>
</div>
)}
{script.default_credentials.password && (
<div>
<dt className="text-sm font-medium text-muted-foreground">
<dt className="text-muted-foreground text-sm font-medium">
Password
</dt>
<dd className="font-mono text-sm text-foreground">
<dd className="text-foreground font-mono text-sm">
{script.default_credentials.password}
</dd>
</div>
@@ -783,7 +849,7 @@ export function ScriptDetailModal({
{/* Notes */}
{script.notes.length > 0 && (
<div>
<h3 className="mb-3 text-lg font-semibold text-foreground">
<h3 className="text-foreground mb-3 text-lg font-semibold">
Notes
</h3>
<ul className="space-y-2">
@@ -798,14 +864,17 @@ export function ScriptDetailModal({
key={index}
className={`rounded-lg p-3 text-sm ${
noteType === "warning"
? "border-l-4 border-warning bg-warning/10 text-warning"
? "border-warning bg-warning/10 text-warning border-l-4"
: noteType === "error"
? "border-l-4 border-destructive bg-destructive/10 text-destructive"
? "border-destructive bg-destructive/10 text-destructive border-l-4"
: "bg-muted text-muted-foreground"
}`}
>
<div className="flex items-start">
<NoteBadge noteType={noteType as 'info' | 'warning' | 'error'} className="mr-2 flex-shrink-0">
<NoteBadge
noteType={noteType as "info" | "warning" | "error"}
className="mr-2 flex-shrink-0"
>
{noteType}
</NoteBadge>
<span>{noteText}</span>
@@ -837,7 +906,13 @@ export function ScriptDetailModal({
<TextViewer
scriptName={
script.install_methods
?.find((method) => method.script?.startsWith("ct/"))
?.find(
(method) =>
method.script &&
(method.script.startsWith("ct/") ||
method.script.startsWith("vm/") ||
method.script.startsWith("tools/")),
)
?.script?.split("/")
.pop() ?? `${script.slug}.sh`
}
@@ -861,6 +936,7 @@ export function ScriptDetailModal({
{script && (
<ExecutionModeModal
scriptName={script.name}
script={script}
isOpen={executionModeOpen}
onClose={() => setExecutionModeOpen(false)}
onExecute={handleExecuteScript}

View File

@@ -33,6 +33,7 @@ interface InstalledScript {
container_status?: 'running' | 'stopped' | 'unknown';
web_ui_ip: string | null;
web_ui_port: number | null;
is_vm?: boolean;
}
interface ScriptInstallationCardProps {
@@ -45,6 +46,7 @@ interface ScriptInstallationCardProps {
onCancel: () => void;
onUpdate: () => void;
onBackup?: () => void;
onClone?: () => void;
onShell: () => void;
onDelete: () => void;
isUpdating: boolean;
@@ -70,6 +72,7 @@ export function ScriptInstallationCard({
onCancel,
onUpdate,
onBackup,
onClone,
onShell,
onDelete,
isUpdating,
@@ -300,7 +303,7 @@ export function ScriptInstallationCard({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48 bg-card border-border">
{script.container_id && (
{script.container_id && !script.is_vm && (
<DropdownMenuItem
onClick={onUpdate}
disabled={containerStatus === 'stopped'}
@@ -318,6 +321,15 @@ export function ScriptInstallationCard({
Backup
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && onClone && (
<DropdownMenuItem
onClick={onClone}
disabled={containerStatus === 'stopped'}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
Clone
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<DropdownMenuItem
onClick={onShell}

View File

@@ -1,9 +1,9 @@
'use client';
"use client";
import { useState } from 'react';
import type { Script, ScriptInstallMethod } from '../../types/script';
import { Button } from './ui/button';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useState } from "react";
import type { Script } from "../../types/script";
import { Button } from "./ui/button";
import { useRegisterModal } from "./modal/ModalStackProvider";
interface ScriptVersionModalProps {
isOpen: boolean;
@@ -12,16 +12,29 @@ interface ScriptVersionModalProps {
script: Script | null;
}
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
export function ScriptVersionModal({
isOpen,
onClose,
onSelectVersion,
script,
}: ScriptVersionModalProps) {
useRegisterModal(isOpen, {
id: "script-version-modal",
allowEscape: true,
onClose,
});
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
if (!isOpen || !script) return null;
// Get available install methods
const installMethods = script.install_methods || [];
const defaultMethod = installMethods.find(method => method.type === 'default');
const alpineMethod = installMethods.find(method => method.type === 'alpine');
const defaultMethod = installMethods.find(
(method) => method.type === "default",
);
const alpineMethod = installMethods.find(
(method) => method.type === "alpine",
);
const handleConfirm = () => {
if (selectedVersion) {
@@ -35,19 +48,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
};
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-2xl rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Select Version</h2>
<div className="border-border flex items-center justify-between border-b p-6">
<h2 className="text-foreground text-xl font-bold">Select Version</h2>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
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>
@@ -55,11 +78,12 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
{/* Content */}
<div className="p-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-foreground mb-2">
<h3 className="text-foreground mb-2 text-lg font-medium">
Choose a version for &quot;{script.name}&quot;
</h3>
<p className="text-sm text-muted-foreground">
Select the version you want to install. Each version has different resource requirements.
<p className="text-muted-foreground text-sm">
Select the version you want to install. Each version has different
resource requirements.
</p>
</div>
@@ -67,25 +91,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
{/* Default Version */}
{defaultMethod && (
<div
onClick={() => handleVersionSelect('default')}
onClick={() => handleVersionSelect("default")}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === 'default'
? 'border-primary bg-primary/10'
: 'border-border bg-card hover:border-primary/50'
selectedVersion === "default"
? "border-primary bg-primary/10"
: "border-border bg-card hover:border-primary/50"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-3">
<div className="mb-3 flex items-center space-x-3">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === 'default'
? 'border-primary bg-primary'
: 'border-border'
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
selectedVersion === "default"
? "border-primary bg-primary"
: "border-border"
}`}
>
{selectedVersion === 'default' && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
{selectedVersion === "default" && (
<svg
className="h-3 w-3 text-white"
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"
@@ -94,27 +122,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</svg>
)}
</div>
<h4 className="text-base font-semibold text-foreground capitalize">
<h4 className="text-foreground text-base font-semibold capitalize">
{defaultMethod.type}
</h4>
</div>
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div className="ml-8 grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
<span className="text-foreground font-medium">
{defaultMethod.resources.cpu} cores
</span>
</div>
<div>
<span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
<span className="text-foreground font-medium">
{defaultMethod.resources.ram} MB
</span>
</div>
<div>
<span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
<span className="text-foreground font-medium">
{defaultMethod.resources.hdd} GB
</span>
</div>
<div>
<span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium">
{defaultMethod.resources.os} {defaultMethod.resources.version}
{defaultMethod.resources.os}{" "}
{defaultMethod.resources.version}
</span>
</div>
</div>
@@ -126,25 +161,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
{/* Alpine Version */}
{alpineMethod && (
<div
onClick={() => handleVersionSelect('alpine')}
onClick={() => handleVersionSelect("alpine")}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === 'alpine'
? 'border-primary bg-primary/10'
: 'border-border bg-card hover:border-primary/50'
selectedVersion === "alpine"
? "border-primary bg-primary/10"
: "border-border bg-card hover:border-primary/50"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-3">
<div className="mb-3 flex items-center space-x-3">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === 'alpine'
? 'border-primary bg-primary'
: 'border-border'
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
selectedVersion === "alpine"
? "border-primary bg-primary"
: "border-border"
}`}
>
{selectedVersion === 'alpine' && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
{selectedVersion === "alpine" && (
<svg
className="h-3 w-3 text-white"
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"
@@ -153,27 +192,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</svg>
)}
</div>
<h4 className="text-base font-semibold text-foreground capitalize">
<h4 className="text-foreground text-base font-semibold capitalize">
{alpineMethod.type}
</h4>
</div>
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div className="ml-8 grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
<span className="text-foreground font-medium">
{alpineMethod.resources.cpu} cores
</span>
</div>
<div>
<span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
<span className="text-foreground font-medium">
{alpineMethod.resources.ram} MB
</span>
</div>
<div>
<span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
<span className="text-foreground font-medium">
{alpineMethod.resources.hdd} GB
</span>
</div>
<div>
<span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium">
{alpineMethod.resources.os} {alpineMethod.resources.version}
{alpineMethod.resources.os}{" "}
{alpineMethod.resources.version}
</span>
</div>
</div>
@@ -184,12 +230,8 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-3 mt-6">
<Button
onClick={onClose}
variant="outline"
size="default"
>
<div className="mt-6 flex justify-end space-x-3">
<Button onClick={onClose} variant="outline" size="default">
Cancel
</Button>
<Button
@@ -197,7 +239,9 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
disabled={!selectedVersion}
variant="default"
size="default"
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
className={
!selectedVersion ? "bg-muted-foreground cursor-not-allowed" : ""
}
>
Continue
</Button>
@@ -207,4 +251,3 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
'use client';
"use client";
import { useState, useEffect } from 'react';
import type { CreateServerData } from '../../types/server';
import { Button } from './ui/button';
import { SSHKeyInput } from './SSHKeyInput';
import { PublicKeyModal } from './PublicKeyModal';
import { Key } from 'lucide-react';
import { useState, useEffect } from "react";
import type { CreateServerData } from "../../types/server";
import { Button } from "./ui/button";
import { SSHKeyInput } from "./SSHKeyInput";
import { PublicKeyModal } from "./PublicKeyModal";
import { Key } from "lucide-react";
interface ServerFormProps {
onSubmit: (data: CreateServerData) => void;
@@ -14,40 +14,47 @@ interface ServerFormProps {
onCancel?: () => void;
}
export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel }: ServerFormProps) {
export function ServerForm({
onSubmit,
initialData,
isEditing = false,
onCancel,
}: ServerFormProps) {
const [formData, setFormData] = useState<CreateServerData>(
initialData ?? {
name: '',
ip: '',
user: '',
password: '',
auth_type: 'password',
ssh_key: '',
ssh_key_passphrase: '',
name: "",
ip: "",
user: "",
password: "",
auth_type: "password",
ssh_key: "",
ssh_key_passphrase: "",
ssh_port: 22,
color: '#3b82f6',
}
color: "#3b82f6",
},
);
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
const [sshKeyError, setSshKeyError] = useState<string>('');
const [errors, setErrors] = useState<
Partial<Record<keyof CreateServerData, string>>
>({});
const [sshKeyError, setSshKeyError] = useState<string>("");
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
const [generatedPublicKey, setGeneratedPublicKey] = useState("");
const [, setIsGeneratedKey] = useState(false);
const [, setGeneratedServerId] = useState<number | null>(null);
useEffect(() => {
const loadColorCodingSetting = async () => {
try {
const response = await fetch('/api/settings/color-coding');
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);
console.error("Error loading color coding setting:", error);
}
};
void loadColorCodingSetting();
@@ -58,15 +65,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
if (!trimmed) return false;
// IPv4 validation
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv4Regex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv4Regex.test(trimmed)) {
return true;
}
// Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0)
let ipv6Address = trimmed;
const zoneIdMatch = trimmed.match(/^(.+)%([a-zA-Z0-9_\-]+)$/);
if (zoneIdMatch) {
const zoneIdMatch = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed);
if (zoneIdMatch?.[1] && zoneIdMatch[2]) {
ipv6Address = zoneIdMatch[1];
// Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen)
const zoneId = zoneIdMatch[2];
@@ -79,10 +87,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
// Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc.
// Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
// Simplified validation: check for valid hex segments separated by colons
const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Pattern =
/^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv6Pattern.test(ipv6Address)) {
// Additional validation: ensure only one :: compression exists
const compressionCount = (ipv6Address.match(/::/g) || []).length;
const compressionCount = (ipv6Address.match(/::/g) ?? []).length;
if (compressionCount <= 1) {
return true;
}
@@ -91,17 +100,19 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
// FQDN/hostname validation (RFC 1123 compliant)
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
// Max length 253 characters, each label max 63 characters
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
const hostnameRegex =
/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (hostnameRegex.test(trimmed) && trimmed.length <= 253) {
// Additional check: each label (between dots) must be max 63 chars
const labels = trimmed.split('.');
if (labels.every(label => label.length > 0 && label.length <= 63)) {
const labels = trimmed.split(".");
if (labels.every((label) => label.length > 0 && label.length <= 63)) {
return true;
}
}
// Also allow simple hostnames without dots (like 'localhost')
const simpleHostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
const simpleHostnameRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
return true;
}
@@ -113,41 +124,44 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
if (!formData.name.trim()) {
newErrors.name = 'Server name is required';
newErrors.name = "Server name is required";
}
if (!formData.ip.trim()) {
newErrors.ip = 'Server address is required';
newErrors.ip = "Server address is required";
} else {
if (!validateServerAddress(formData.ip)) {
newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
newErrors.ip =
"Please enter a valid IP address (IPv4/IPv6) or hostname";
}
}
if (!formData.user.trim()) {
newErrors.user = 'Username is required';
newErrors.user = "Username 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';
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') {
const authType = formData.auth_type ?? "password";
if (authType === "password") {
if (!formData.password?.trim()) {
newErrors.password = 'Password is required for password authentication';
}
}
if (authType === 'key') {
if (!formData.ssh_key?.trim()) {
newErrors.ssh_key = 'SSH key is required for key authentication';
newErrors.password = "Password is required for password authentication";
}
}
if (authType === "key") {
if (!formData.ssh_key?.trim()) {
newErrors.ssh_key = "SSH key is required for key authentication";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0 && !sshKeyError;
@@ -158,348 +172,411 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
if (validateForm()) {
onSubmit(formData);
if (!isEditing) {
setFormData({
name: '',
ip: '',
user: '',
password: '',
auth_type: 'password',
ssh_key: '',
ssh_key_passphrase: '',
setFormData({
name: "",
ip: "",
user: "",
password: "",
auth_type: "password",
ssh_key: "",
ssh_key_passphrase: "",
ssh_port: 22,
color: '#3b82f6'
color: "#3b82f6",
});
}
}
};
const handleChange = (field: keyof CreateServerData) => (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
// Special handling for numeric ssh_port: keep it strictly numeric
if (field === 'ssh_port') {
const raw = (e.target as HTMLInputElement).value ?? '';
const digitsOnly = raw.replace(/\D+/g, '');
setFormData(prev => ({
...prev,
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
}));
if (errors.ssh_port) {
setErrors(prev => ({ ...prev, ssh_port: undefined }));
const handleChange =
(field: keyof CreateServerData) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
// Special handling for numeric ssh_port: keep it strictly numeric
if (field === "ssh_port") {
const raw = (e.target as HTMLInputElement).value ?? "";
const digitsOnly = raw.replace(/\D+/g, "");
setFormData((prev) => ({
...prev,
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
}));
if (errors.ssh_port) {
setErrors((prev) => ({ ...prev, ssh_port: undefined }));
}
return;
}
return;
}
setFormData(prev => ({ ...prev, [field]: (e.target as HTMLInputElement).value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
// Reset generated key state when switching auth types
if (field === 'auth_type') {
setIsGeneratedKey(false);
setGeneratedPublicKey('');
}
};
setFormData((prev) => ({
...prev,
[field]: (e.target as HTMLInputElement).value,
}));
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
// Reset generated key state when switching auth types
if (field === "auth_type") {
setIsGeneratedKey(false);
setGeneratedPublicKey("");
}
};
const handleGenerateKeyPair = async () => {
setIsGeneratingKey(true);
try {
const response = await fetch('/api/servers/generate-keypair', {
method: 'POST',
const response = await fetch("/api/servers/generate-keypair", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error('Failed to generate key pair');
throw new Error("Failed to generate key pair");
}
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
const data = (await response.json()) as {
success: boolean;
privateKey?: string;
publicKey?: string;
serverId?: number;
error?: string;
};
if (data.success) {
const serverId = data.serverId ?? 0;
const keyPath = `data/ssh-keys/server_${serverId}_key`;
setFormData(prev => ({
...prev,
ssh_key: data.privateKey ?? '',
setFormData((prev) => ({
...prev,
ssh_key: data.privateKey ?? "",
ssh_key_path: keyPath,
key_generated: true
key_generated: true,
}));
setGeneratedPublicKey(data.publicKey ?? '');
setGeneratedPublicKey(data.publicKey ?? "");
setGeneratedServerId(serverId);
setIsGeneratedKey(true);
setShowPublicKeyModal(true);
setSshKeyError('');
setSshKeyError("");
} else {
throw new Error(data.error ?? 'Failed to generate key pair');
throw new Error(data.error ?? "Failed to generate key pair");
}
} catch (error) {
console.error('Error generating key pair:', error);
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
console.error("Error generating key pair:", error);
setSshKeyError(
error instanceof Error ? error.message : "Failed to generate key pair",
);
} finally {
setIsGeneratingKey(false);
}
};
const handleSSHKeyChange = (value: string) => {
setFormData(prev => ({ ...prev, ssh_key: value }));
setFormData((prev) => ({ ...prev, ssh_key: value }));
if (errors.ssh_key) {
setErrors(prev => ({ ...prev, ssh_key: undefined }));
setErrors((prev) => ({ ...prev, ssh_key: undefined }));
}
};
return (
<>
<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-muted-foreground mb-1">
Server Name *
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={handleChange('name')}
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-destructive">{errors.name}</p>}
</div>
<div>
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
Host/IP Address *
</label>
<input
type="text"
id="ip"
value={formData.ip}
onChange={handleChange('ip')}
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, server.example.com, 2001:db8::1, or fe80::...%eth0"
/>
{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-muted-foreground mb-1">
Username *
</label>
<input
type="text"
id="user"
value={formData.user}
onChange={handleChange('user')}
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-destructive">{errors.user}</p>}
</div>
<div>
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
SSH Port
</label>
<input
type="number"
id="ssh_port"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={formData.ssh_port ?? 22}
onChange={handleChange('ssh_port')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ssh_port ? 'border-destructive' : 'border-border'
}`}
placeholder="22"
min={1}
max={65535}
/>
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
</div>
<div>
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
Authentication Type *
</label>
<select
id="auth_type"
value={formData.auth_type ?? 'password'}
onChange={handleChange('auth_type')}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
>
<option value="password">Password Only</option>
<option value="key">SSH Key Only</option>
</select>
</div>
{colorCodingEnabled && (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
Server Color
<label
htmlFor="name"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Server Name *
</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>
<input
type="text"
id="name"
value={formData.name}
onChange={handleChange("name")}
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
errors.name ? "border-destructive" : "border-border"
}`}
placeholder="e.g., Production Server"
/>
{errors.name && (
<p className="text-destructive mt-1 text-sm">{errors.name}</p>
)}
</div>
)}
</div>
{/* Password Authentication */}
{formData.auth_type === 'password' && (
<div>
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
Password *
</label>
<input
type="password"
id="password"
value={formData.password ?? ''}
onChange={handleChange('password')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.password ? 'border-destructive' : 'border-border'
}`}
placeholder="Enter password"
/>
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
</div>
)}
{/* SSH Key Authentication */}
{formData.auth_type === 'key' && (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-muted-foreground">
SSH Private Key *
</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerateKeyPair}
disabled={isGeneratingKey}
className="gap-2"
<label
htmlFor="ip"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Host/IP Address *
</label>
<input
type="text"
id="ip"
value={formData.ip}
onChange={handleChange("ip")}
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
errors.ip ? "border-destructive" : "border-border"
}`}
placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0"
/>
{errors.ip && (
<p className="text-destructive mt-1 text-sm">{errors.ip}</p>
)}
</div>
<div>
<label
htmlFor="user"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Username *
</label>
<input
type="text"
id="user"
value={formData.user}
onChange={handleChange("user")}
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
errors.user ? "border-destructive" : "border-border"
}`}
placeholder="e.g., root"
/>
{errors.user && (
<p className="text-destructive mt-1 text-sm">{errors.user}</p>
)}
</div>
<div>
<label
htmlFor="ssh_port"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
SSH Port
</label>
<input
type="number"
id="ssh_port"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={formData.ssh_port ?? 22}
onChange={handleChange("ssh_port")}
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
errors.ssh_port ? "border-destructive" : "border-border"
}`}
placeholder="22"
min={1}
max={65535}
/>
{errors.ssh_port && (
<p className="text-destructive mt-1 text-sm">{errors.ssh_port}</p>
)}
</div>
<div>
<label
htmlFor="auth_type"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Authentication Type *
</label>
<select
id="auth_type"
value={formData.auth_type ?? "password"}
onChange={handleChange("auth_type")}
className="bg-card text-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
>
<option value="password">Password Only</option>
<option value="key">SSH Key Only</option>
</select>
</div>
{colorCodingEnabled && (
<div>
<label
htmlFor="color"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
<Key className="h-4 w-4" />
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
</Button>
</div>
{/* Show manual key input only if no key has been generated */}
{!formData.key_generated && (
<>
<SSHKeyInput
value={formData.ssh_key ?? ''}
onChange={handleSSHKeyChange}
onError={setSshKeyError}
Server Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id="color"
value={formData.color ?? "#3b82f6"}
onChange={handleChange("color")}
className="border-border h-10 w-20 cursor-pointer rounded border"
/>
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
</>
)}
{/* Show generated key status */}
{formData.key_generated && (
<div className="p-3 bg-success/10 border border-success/20 rounded-md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium text-success-foreground">
SSH key pair generated successfully
</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowPublicKeyModal(true)}
className="gap-2 border-info/20 text-info bg-info/10 hover:bg-info/20"
>
<Key className="h-4 w-4" />
View Public Key
</Button>
</div>
<p className="text-xs text-success/80 mt-1">
The private key has been generated and will be saved with the server.
</p>
<span className="text-muted-foreground text-sm">
Choose a color to identify this server
</span>
</div>
)}
</div>
</div>
)}
</div>
{/* Password Authentication */}
{formData.auth_type === "password" && (
<div>
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
SSH Key Passphrase (Optional)
<label
htmlFor="password"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
Password *
</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"
id="password"
value={formData.password ?? ""}
onChange={handleChange("password")}
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
errors.password ? "border-destructive" : "border-border"
}`}
placeholder="Enter password"
/>
<p className="mt-1 text-xs text-muted-foreground">
Only required if your SSH key is encrypted with a passphrase
</p>
{errors.password && (
<p className="text-destructive mt-1 text-sm">{errors.password}</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
type="button"
onClick={onCancel}
variant="outline"
size="default"
className="w-full sm:w-auto order-2 sm:order-1"
>
Cancel
</Button>
)}
<Button
type="submit"
variant="default"
size="default"
className="w-full sm:w-auto order-1 sm:order-2"
>
{isEditing ? 'Update Server' : 'Add Server'}
</Button>
</div>
</form>
{/* Public Key Modal */}
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => setShowPublicKeyModal(false)}
publicKey={generatedPublicKey}
serverName={formData.name || 'New Server'}
serverIp={formData.ip}
/>
{/* SSH Key Authentication */}
{formData.auth_type === "key" && (
<div className="space-y-4">
<div>
<div className="mb-1 flex items-center justify-between">
<label className="text-muted-foreground block text-sm font-medium">
SSH Private Key *
</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerateKeyPair}
disabled={isGeneratingKey}
className="gap-2"
>
<Key className="h-4 w-4" />
{isGeneratingKey ? "Generating..." : "Generate Key Pair"}
</Button>
</div>
{/* Show manual key input only if no key has been generated */}
{!formData.key_generated && (
<>
<SSHKeyInput
value={formData.ssh_key ?? ""}
onChange={handleSSHKeyChange}
onError={setSshKeyError}
/>
{errors.ssh_key && (
<p className="text-destructive mt-1 text-sm">
{errors.ssh_key}
</p>
)}
{sshKeyError && (
<p className="text-destructive mt-1 text-sm">
{sshKeyError}
</p>
)}
</>
)}
{/* Show generated key status */}
{formData.key_generated && (
<div className="bg-success/10 border-success/20 rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg
className="text-success h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="text-success-foreground text-sm font-medium">
SSH key pair generated successfully
</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowPublicKeyModal(true)}
className="border-info/20 text-info bg-info/10 hover:bg-info/20 gap-2"
>
<Key className="h-4 w-4" />
View Public Key
</Button>
</div>
<p className="text-success/80 mt-1 text-xs">
The private key has been generated and will be saved with
the server.
</p>
</div>
)}
</div>
<div>
<label
htmlFor="ssh_key_passphrase"
className="text-muted-foreground mb-1 block text-sm font-medium"
>
SSH Key Passphrase (Optional)
</label>
<input
type="password"
id="ssh_key_passphrase"
value={formData.ssh_key_passphrase ?? ""}
onChange={handleChange("ssh_key_passphrase")}
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
placeholder="Enter passphrase for encrypted key"
/>
<p className="text-muted-foreground mt-1 text-xs">
Only required if your SSH key is encrypted with a passphrase
</p>
</div>
</div>
)}
<div className="flex flex-col justify-end space-y-2 pt-4 sm:flex-row sm:space-y-0 sm:space-x-3">
{isEditing && onCancel && (
<Button
type="button"
onClick={onCancel}
variant="outline"
size="default"
className="order-2 w-full sm:order-1 sm:w-auto"
>
Cancel
</Button>
)}
<Button
type="submit"
variant="default"
size="default"
className="order-1 w-full sm:order-2 sm:w-auto"
>
{isEditing ? "Update Server" : "Add Server"}
</Button>
</div>
</form>
{/* Public Key Modal */}
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => setShowPublicKeyModal(false)}
publicKey={generatedPublicKey}
serverName={formData.name || "New Server"}
serverIp={formData.ip}
/>
</>
);
}

View File

@@ -1,12 +1,18 @@
'use client';
"use client";
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Database, RefreshCw, CheckCircle, Lock, AlertCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from '~/trpc/react';
import { PBSCredentialsModal } from './PBSCredentialsModal';
import type { Storage } from '~/server/services/storageService';
import { useState } from "react";
import { Button } from "./ui/button";
import {
Database,
RefreshCw,
CheckCircle,
Lock,
AlertCircle,
} from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { api } from "~/trpc/react";
import { PBSCredentialsModal } from "./PBSCredentialsModal";
import type { Storage } from "~/server/services/storageService";
interface ServerStoragesModalProps {
isOpen: boolean;
@@ -19,30 +25,38 @@ export function ServerStoragesModal({
isOpen,
onClose,
serverId,
serverName
serverName,
}: ServerStoragesModalProps) {
const [forceRefresh, setForceRefresh] = useState(false);
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(null);
const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery(
{ serverId, forceRefresh },
{ enabled: isOpen }
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(
null,
);
const { data, isLoading, refetch } =
api.installedScripts.getBackupStorages.useQuery(
{ serverId, forceRefresh },
{ enabled: isOpen },
);
// Fetch all PBS credentials for this server to show status indicators
const { data: allCredentials } = api.pbsCredentials.getAllCredentialsForServer.useQuery(
{ serverId },
{ enabled: isOpen }
);
const { data: allCredentials } =
api.pbsCredentials.getAllCredentialsForServer.useQuery(
{ serverId },
{ enabled: isOpen },
);
const credentialsMap = new Map<string, boolean>();
if (allCredentials?.success) {
allCredentials.credentials.forEach(c => {
credentialsMap.set(c.storage_name, true);
allCredentials.credentials.forEach((c: { storage_name: string }) => {
credentialsMap.set(String(c.storage_name), true);
});
}
useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose });
useRegisterModal(isOpen, {
id: "server-storages-modal",
allowEscape: true,
onClose,
});
const handleRefresh = () => {
setForceRefresh(true);
@@ -53,16 +67,16 @@ export function ServerStoragesModal({
if (!isOpen) return null;
const storages = data?.success ? data.storages : [];
const backupStorages = storages.filter(s => s.supportsBackup);
const backupStorages = storages.filter((s) => s.supportsBackup);
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-3xl w-full max-h-[90vh] flex flex-col border border-border">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-3xl flex-col rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center gap-3">
<Database className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">
<Database className="text-primary h-6 w-6" />
<h2 className="text-card-foreground text-2xl font-bold">
Storages for {serverName}
</h2>
</div>
@@ -73,7 +87,9 @@ export function ServerStoragesModal({
size="sm"
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
<RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button
@@ -82,8 +98,18 @@ export function ServerStoragesModal({
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
</div>
@@ -92,96 +118,112 @@ export function ServerStoragesModal({
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
<div className="py-8 text-center">
<div className="border-primary mb-4 inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground">Loading storages...</p>
</div>
) : !data?.success ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<div className="py-8 text-center">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<p className="text-foreground mb-2">Failed to load storages</p>
<p className="text-sm text-muted-foreground mb-4">
{data?.error ?? 'Unknown error occurred'}
<p className="text-muted-foreground mb-4 text-sm">
{data?.error ?? "Unknown error occurred"}
</p>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
</div>
) : storages.length === 0 ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<div className="py-8 text-center">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<p className="text-foreground mb-2">No storages found</p>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Make sure your server has storages configured.
</p>
</div>
) : (
<>
{data.cached && (
<div className="mb-4 p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
Showing cached data. Click Refresh to fetch latest from server.
<div className="bg-muted/50 text-muted-foreground mb-4 rounded-lg p-3 text-sm">
Showing cached data. Click Refresh to fetch latest from
server.
</div>
)}
<div className="space-y-3">
{storages.map((storage) => {
const isBackupCapable = storage.supportsBackup;
return (
<div
key={storage.name}
className={`p-4 border rounded-lg ${
className={`rounded-lg border p-4 ${
isBackupCapable
? 'border-success/50 bg-success/5'
: 'border-border bg-card'
? "border-success/50 bg-success/5"
: "border-border bg-card"
}`}
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h3 className="font-medium text-foreground">{storage.name}</h3>
<div className="mb-2 flex flex-wrap items-center gap-2">
<h3 className="text-foreground font-medium">
{storage.name}
</h3>
{isBackupCapable && (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
<span className="bg-success/20 text-success border-success/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
<CheckCircle className="h-3 w-3" />
Backup
</span>
)}
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
<span className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs font-medium">
{storage.type}
</span>
{storage.type === 'pbs' && (
credentialsMap.has(storage.name) ? (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
{storage.type === "pbs" &&
(credentialsMap.has(storage.name) ? (
<span className="bg-success/20 text-success border-success/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
<CheckCircle className="h-3 w-3" />
Credentials Configured
</span>
) : (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-warning/20 text-warning border border-warning/30 flex items-center gap-1">
<span className="bg-warning/20 text-warning border-warning/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
<AlertCircle className="h-3 w-3" />
Credentials Needed
</span>
)
)}
))}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="text-muted-foreground space-y-1 text-sm">
<div>
<span className="font-medium">Content:</span> {storage.content.join(', ')}
<span className="font-medium">Content:</span>{" "}
{storage.content.join(", ")}
</div>
{storage.nodes && storage.nodes.length > 0 && (
<div>
<span className="font-medium">Nodes:</span> {storage.nodes.join(', ')}
<span className="font-medium">Nodes:</span>{" "}
{storage.nodes.join(", ")}
</div>
)}
{Object.entries(storage)
.filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
.filter(
([key]) =>
![
"name",
"type",
"content",
"supportsBackup",
"nodes",
].includes(key),
)
.map(([key, value]) => (
<div key={key}>
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span> {String(value)}
<span className="font-medium capitalize">
{key.replace(/_/g, " ")}:
</span>{" "}
{String(value)}
</div>
))}
</div>
{storage.type === 'pbs' && (
<div className="mt-3 pt-3 border-t border-border">
{storage.type === "pbs" && (
<div className="border-border mt-3 border-t pt-3">
<Button
onClick={() => setSelectedPBSStorage(storage)}
variant="outline"
@@ -189,7 +231,10 @@ export function ServerStoragesModal({
className="flex items-center gap-2"
>
<Lock className="h-4 w-4" />
{credentialsMap.has(storage.name) ? 'Edit' : 'Configure'} Credentials
{credentialsMap.has(storage.name)
? "Edit"
: "Configure"}{" "}
Credentials
</Button>
</div>
)}
@@ -198,11 +243,13 @@ export function ServerStoragesModal({
);
})}
</div>
{backupStorages.length > 0 && (
<div className="mt-6 p-4 bg-success/10 border border-success/20 rounded-lg">
<p className="text-sm text-success font-medium">
{backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups
<div className="bg-success/10 border-success/20 mt-6 rounded-lg border p-4">
<p className="text-success text-sm font-medium">
{backupStorages.length} storage
{backupStorages.length !== 1 ? "s" : ""} available for
backups
</p>
</div>
)}
@@ -210,7 +257,7 @@ export function ServerStoragesModal({
)}
</div>
</div>
{/* PBS Credentials Modal */}
{selectedPBSStorage && (
<PBSCredentialsModal
@@ -224,4 +271,3 @@ export function ServerStoragesModal({
</div>
);
}

View File

@@ -13,6 +13,10 @@ interface StorageSelectionModalProps {
storages: Storage[];
isLoading: boolean;
onRefresh: () => void;
title?: string;
description?: string;
filterFn?: (storage: Storage) => boolean;
showBackupTag?: boolean;
}
export function StorageSelectionModal({
@@ -21,7 +25,11 @@ export function StorageSelectionModal({
onSelect,
storages,
isLoading,
onRefresh
onRefresh,
title = 'Select Storage',
description = 'Select a storage to use.',
filterFn,
showBackupTag = true
}: StorageSelectionModalProps) {
const [selectedStorage, setSelectedStorage] = useState<Storage | null>(null);
@@ -41,8 +49,8 @@ export function StorageSelectionModal({
onClose();
};
// Filter to show only backup-capable storages
const backupStorages = storages.filter(s => s.supportsBackup);
// Filter storages using filterFn if provided, otherwise filter to show only backup-capable storages
const filteredStorages = filterFn ? storages.filter(filterFn) : storages.filter(s => s.supportsBackup);
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
@@ -51,7 +59,7 @@ export function StorageSelectionModal({
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<Database className="h-6 w-6 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">Select Backup Storage</h2>
<h2 className="text-2xl font-bold text-card-foreground">{title}</h2>
</div>
<Button
onClick={handleClose}
@@ -72,7 +80,7 @@ export function StorageSelectionModal({
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
<p className="text-muted-foreground">Loading storages...</p>
</div>
) : backupStorages.length === 0 ? (
) : filteredStorages.length === 0 ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-foreground mb-2">No backup-capable storages found</p>
@@ -87,12 +95,12 @@ export function StorageSelectionModal({
) : (
<>
<p className="text-sm text-muted-foreground mb-4">
Select a storage to use for the backup. Only storages that support backups are shown.
{description}
</p>
{/* Storage List */}
<div className="space-y-2 max-h-96 overflow-y-auto mb-4">
{backupStorages.map((storage) => (
{filteredStorages.map((storage) => (
<div
key={storage.name}
onClick={() => setSelectedStorage(storage)}
@@ -106,9 +114,11 @@ export function StorageSelectionModal({
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">{storage.name}</h3>
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
Backup
</span>
{showBackupTag && (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
Backup
</span>
)}
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
{storage.type}
</span>

View File

@@ -13,9 +13,15 @@ interface TerminalProps {
isUpdate?: boolean;
isShell?: boolean;
isBackup?: boolean;
isClone?: boolean;
containerId?: string;
storage?: string;
backupStorage?: string;
executionId?: string;
cloneCount?: number;
hostnames?: string[];
containerType?: 'lxc' | 'vm';
envVars?: Record<string, string | number | boolean>;
}
interface TerminalMessage {
@@ -24,7 +30,7 @@ interface TerminalMessage {
timestamp: number;
}
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: TerminalProps) {
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, isClone = false, containerId, storage, backupStorage, executionId: propExecutionId, cloneCount, hostnames, containerType, envVars }: TerminalProps) {
const [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false);
@@ -39,7 +45,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const fitAddonRef = useRef<any>(null);
const wsRef = useRef<WebSocket | null>(null);
const inputHandlerRef = useRef<((data: string) => void) | null>(null);
const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
const [executionId, setExecutionId] = useState(() => propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
// Update executionId when propExecutionId changes
useEffect(() => {
if (propExecutionId) {
setExecutionId(propExecutionId);
}
}, [propExecutionId]);
const effectiveExecutionId = propExecutionId ?? executionId;
const isConnectingRef = useRef<boolean>(false);
const hasConnectedRef = useRef<boolean>(false);
@@ -277,7 +292,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
executionId: effectiveExecutionId,
input: data
};
wsRef.current.send(JSON.stringify(message));
@@ -325,9 +340,11 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// 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);
// Use propExecutionId if provided, otherwise generate a new one
const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (!propExecutionId) {
setExecutionId(newExecutionId);
}
const message = {
action: 'start',
@@ -338,9 +355,14 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
isUpdate,
isShell,
isBackup,
isClone,
containerId,
storage,
backupStorage
backupStorage,
cloneCount,
hostnames,
containerType,
envVars
};
ws.send(JSON.stringify(message));
}
@@ -380,13 +402,15 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
wsRef.current.close();
}
};
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile, envVars]);
const startScript = () => {
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);
// Generate a new execution ID for each script run (unless propExecutionId is provided)
const newExecutionId = propExecutionId ?? `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (!propExecutionId) {
setExecutionId(newExecutionId);
}
setIsStopped(false);
wsRef.current.send(JSON.stringify({
@@ -395,9 +419,17 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
executionId: newExecutionId,
mode,
server,
envVars,
isUpdate,
isShell,
containerId
isBackup,
isClone,
containerId,
storage,
backupStorage,
cloneCount,
hostnames,
containerType
}));
}
};

View File

@@ -1,10 +1,10 @@
'use client';
"use client";
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';
import type { Script } from '../../types/script';
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";
import type { Script } from "../../types/script";
interface TextViewerProps {
scriptName: string;
@@ -14,154 +14,161 @@ interface TextViewerProps {
}
interface ScriptContent {
ctScript?: string;
mainScript?: string;
installScript?: string;
alpineCtScript?: string;
alpineMainScript?: string;
alpineInstallScript?: string;
}
export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
export function TextViewer({
scriptName,
isOpen,
onClose,
script,
}: TextViewerProps) {
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
const [activeTab, setActiveTab] = useState<"main" | "install">("main");
const [selectedVersion, setSelectedVersion] = useState<"default" | "alpine">(
"default",
);
// Extract slug from script name (remove .sh extension)
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
// Check if alpine variant exists
const hasAlpineVariant = script?.install_methods?.some(
method => method.type === 'alpine' && method.script?.startsWith('ct/')
const slug = scriptName.replace(/\.sh$/, "").replace(/^alpine-/, "");
// Get default and alpine install methods
const defaultMethod = script?.install_methods?.find(
(method) => method.type === "default",
);
// Get script names for default and alpine versions
const defaultScriptName = scriptName.replace(/^alpine-/, '');
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
const alpineMethod = script?.install_methods?.find(
(method) => method.type === "alpine",
);
// Check if alpine variant exists
const hasAlpineVariant = !!alpineMethod;
// Get script paths from install_methods
const defaultScriptPath = defaultMethod?.script;
const alpineScriptPath = alpineMethod?.script;
// Determine if install scripts exist (only for ct/ scripts typically)
const hasInstallScript =
defaultScriptPath?.startsWith("ct/") ?? alpineScriptPath?.startsWith("ct/");
// Get script names for display
const defaultScriptName = scriptName.replace(/^alpine-/, "");
const loadScriptContent = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// Build fetch requests for default version
// Build fetch requests based on actual script paths from install_methods
const requests: Promise<Response>[] = [];
// Default CT script
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
);
// Tools, VM, VW scripts
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
);
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`)
);
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`)
);
// Default install script
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
);
// Alpine versions if variant exists
if (hasAlpineVariant) {
const requestTypes: Array<
"default-main" | "default-install" | "alpine-main" | "alpine-install"
> = [];
// Default main script (ct/, vm/, tools/, etc.)
if (defaultScriptPath) {
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
);
requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`,
),
);
requestTypes.push("default-main");
}
// Default install script (only for ct/ scripts)
if (hasInstallScript && defaultScriptPath?.startsWith("ct/")) {
requests.push(
fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`,
),
);
requestTypes.push("default-install");
}
// Alpine main script
if (hasAlpineVariant && alpineScriptPath) {
requests.push(
fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`,
),
);
requestTypes.push("alpine-main");
}
// Alpine install script (only for ct/ scripts)
if (
hasAlpineVariant &&
hasInstallScript &&
alpineScriptPath?.startsWith("ct/")
) {
requests.push(
fetch(
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`,
),
);
requestTypes.push("alpine-install");
}
const responses = await Promise.allSettled(requests);
const content: ScriptContent = {};
let responseIndex = 0;
// Default CT script
const ctResponse = responses[responseIndex];
if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (ctData.result?.data?.json?.success) {
content.ctScript = ctData.result.data.json.content;
}
}
responseIndex++;
// Tools script
const toolsResponse = responses[responseIndex];
if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) {
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (toolsData.result?.data?.json?.success) {
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
}
}
responseIndex++;
// VM script
const vmResponse = responses[responseIndex];
if (vmResponse?.status === 'fulfilled' && vmResponse.value.ok) {
const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (vmData.result?.data?.json?.success) {
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too
}
}
responseIndex++;
// VW script
const vwResponse = responses[responseIndex];
if (vwResponse?.status === 'fulfilled' && vwResponse.value.ok) {
const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (vwData.result?.data?.json?.success) {
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too
}
}
responseIndex++;
// Default install script
const installResponse = responses[responseIndex];
if (installResponse?.status === 'fulfilled' && installResponse.value.ok) {
const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (installData.result?.data?.json?.success) {
content.installScript = installData.result.data.json.content;
}
}
responseIndex++;
// Alpine CT script
if (hasAlpineVariant) {
const alpineCtResponse = responses[responseIndex];
if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) {
const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (alpineCtData.result?.data?.json?.success) {
content.alpineCtScript = alpineCtData.result.data.json.content;
// Process responses based on their types
await Promise.all(
responses.map(async (response, index) => {
if (response.status === "fulfilled" && response.value.ok) {
try {
const data = (await response.value.json()) as {
result?: {
data?: { json?: { success?: boolean; content?: string } };
};
};
const type = requestTypes[index];
if (
data.result?.data?.json?.success &&
data.result.data.json.content
) {
switch (type) {
case "default-main":
content.mainScript = data.result.data.json.content;
break;
case "default-install":
content.installScript = data.result.data.json.content;
break;
case "alpine-main":
content.alpineMainScript = data.result.data.json.content;
break;
case "alpine-install":
content.alpineInstallScript = data.result.data.json.content;
break;
}
}
} catch {
// Ignore errors
}
}
}
responseIndex++;
}
// Alpine install script
if (hasAlpineVariant) {
const alpineInstallResponse = responses[responseIndex];
if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) {
const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
if (alpineInstallData.result?.data?.json?.success) {
content.alpineInstallScript = alpineInstallData.result.data.json.content;
}
}
}
}),
);
setScriptContent(content);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load script content');
setError(
err instanceof Error ? err.message : "Failed to load script content",
);
} finally {
setIsLoading(false);
}
}, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
}, [
defaultScriptPath,
alpineScriptPath,
slug,
hasAlpineVariant,
hasInstallScript,
]);
useEffect(() => {
if (isOpen && scriptName) {
@@ -179,51 +186,63 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
return (
<div
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onClick={handleBackdropClick}
>
<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">
<div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-6xl flex-col rounded-lg border shadow-xl sm:mx-0">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center space-x-4 flex-1">
<h2 className="text-2xl font-bold text-foreground">
<div className="border-border flex items-center justify-between border-b p-6">
<div className="flex flex-1 items-center space-x-4">
<h2 className="text-foreground text-2xl font-bold">
Script Viewer: {defaultScriptName}
</h2>
{hasAlpineVariant && (
<div className="flex space-x-2">
<Button
variant={selectedVersion === 'default' ? 'default' : 'outline'}
onClick={() => setSelectedVersion('default')}
variant={
selectedVersion === "default" ? "default" : "outline"
}
onClick={() => setSelectedVersion("default")}
className="px-3 py-1 text-sm"
>
Default
</Button>
<Button
variant={selectedVersion === 'alpine' ? 'default' : 'outline'}
onClick={() => setSelectedVersion('alpine')}
variant={selectedVersion === "alpine" ? "default" : "outline"}
onClick={() => setSelectedVersion("alpine")}
className="px-3 py-1 text-sm"
>
Alpine
</Button>
</div>
)}
{((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
(selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
{/* Boolean logic intentionally uses || for truthiness checks - eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{((selectedVersion === "default" &&
Boolean(
scriptContent.mainScript ?? scriptContent.installScript,
)) ||
(selectedVersion === "alpine" &&
Boolean(
scriptContent.alpineMainScript ??
scriptContent.alpineInstallScript,
))) && (
<div className="flex space-x-2">
<Button
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('ct')}
variant={activeTab === "main" ? "outline" : "ghost"}
onClick={() => setActiveTab("main")}
className="px-3 py-1 text-sm"
>
CT Script
</Button>
<Button
variant={activeTab === 'install' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('install')}
className="px-3 py-1 text-sm"
>
Install Script
Script
</Button>
{hasInstallScript && (
<Button
variant={activeTab === "install" ? "outline" : "ghost"}
onClick={() => setActiveTab("install")}
className="px-3 py-1 text-sm"
>
Install Script
</Button>
)}
</div>
)}
</div>
@@ -231,92 +250,108 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
onClick={onClose}
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" />
<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>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex flex-1 flex-col overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">Loading script content...</div>
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-lg">
Loading script content...
</div>
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-destructive">Error: {error}</div>
<div className="flex h-full items-center justify-center">
<div className="text-destructive text-lg">Error: {error}</div>
</div>
) : (
<div className="flex-1 overflow-auto">
{activeTab === 'ct' && (
selectedVersion === 'default' && scriptContent.ctScript ? (
{activeTab === "main" &&
(selectedVersion === "default" && scriptContent.mainScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
padding: "1rem",
fontSize: "14px",
lineHeight: "1.5",
minHeight: "100%",
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.ctScript}
{scriptContent.mainScript}
</SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
) : selectedVersion === "alpine" &&
scriptContent.alpineMainScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
padding: "1rem",
fontSize: "14px",
lineHeight: "1.5",
minHeight: "100%",
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.alpineCtScript}
{scriptContent.alpineMainScript}
</SyntaxHighlighter>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-lg">
{selectedVersion === "default"
? "Default script not found"
: "Alpine script not found"}
</div>
</div>
)
)}
{activeTab === 'install' && (
selectedVersion === 'default' && scriptContent.installScript ? (
))}
{activeTab === "install" &&
(selectedVersion === "default" &&
scriptContent.installScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
padding: "1rem",
fontSize: "14px",
lineHeight: "1.5",
minHeight: "100%",
}}
showLineNumbers={true}
wrapLines={true}
>
{scriptContent.installScript}
</SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
) : selectedVersion === "alpine" &&
scriptContent.alpineInstallScript ? (
<SyntaxHighlighter
language="bash"
style={tomorrow}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '14px',
lineHeight: '1.5',
minHeight: '100%'
padding: "1rem",
fontSize: "14px",
lineHeight: "1.5",
minHeight: "100%",
}}
showLineNumbers={true}
wrapLines={true}
@@ -324,13 +359,14 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
{scriptContent.alpineInstallScript}
</SyntaxHighlighter>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-lg">
{selectedVersion === "default"
? "Default install script not found"
: "Alpine install script not found"}
</div>
</div>
)
)}
))}
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useContext, useEffect, useState, startTransition } from 'react';
type Theme = 'light' | 'dark';
@@ -31,9 +31,13 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
useEffect(() => {
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
setThemeState(savedTheme);
startTransition(() => {
setThemeState(savedTheme);
});
}
setMounted(true);
startTransition(() => {
setMounted(true);
});
}, []);
// Apply theme to document element

View File

@@ -0,0 +1,234 @@
"use client";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import { X, ExternalLink, Calendar, Tag, AlertTriangle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface UpdateConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
releaseInfo: {
tagName: string;
name: string;
publishedAt: string;
htmlUrl: string;
body?: string;
} | null;
currentVersion: string;
latestVersion: string;
}
export function UpdateConfirmationModal({
isOpen,
onClose,
onConfirm,
releaseInfo,
currentVersion,
latestVersion,
}: UpdateConfirmationModalProps) {
useRegisterModal(isOpen, {
id: "update-confirmation-modal",
allowEscape: true,
onClose,
});
if (!isOpen || !releaseInfo) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border shadow-xl">
{/* Header */}
<div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center gap-3">
<AlertTriangle className="text-warning h-6 w-6" />
<div>
<h2 className="text-card-foreground text-2xl font-bold">
Confirm Update
</h2>
<p className="text-muted-foreground mt-1 text-sm">
Review the changelog before proceeding with the update
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 space-y-4 overflow-y-auto p-6">
{/* Version Info */}
<div className="bg-muted/50 border-border rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-card-foreground text-lg font-semibold">
{releaseInfo.name || releaseInfo.tagName}
</h3>
<Badge variant="default" className="text-xs">
Latest
</Badge>
</div>
<Button
variant="ghost"
size="sm"
asChild
className="h-8 w-8 p-0"
>
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
<div className="text-muted-foreground mb-3 flex items-center gap-4 text-sm">
<div className="flex items-center gap-1">
<Tag className="h-4 w-4" />
<span>{releaseInfo.tagName}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>
{new Date(releaseInfo.publishedAt).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
},
)}
</span>
</div>
</div>
<div className="text-muted-foreground text-sm">
<span>Updating from </span>
<span className="text-card-foreground font-medium">
v{currentVersion}
</span>
<span> to </span>
<span className="text-card-foreground font-medium">
v{latestVersion}
</span>
</div>
</div>
{/* Changelog */}
{releaseInfo.body ? (
<div className="border-border bg-card rounded-lg border p-6">
<h4 className="text-md text-card-foreground mb-4 font-semibold">
Changelog
</h4>
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-card-foreground mt-6 mb-4 text-2xl font-bold">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-card-foreground mt-5 mb-3 text-xl font-semibold">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-card-foreground mt-4 mb-2 text-lg font-medium">
{children}
</h3>
),
p: ({ children }) => (
<p className="text-card-foreground mb-3 leading-relaxed">
{children}
</p>
),
ul: ({ children }) => (
<ul className="text-card-foreground mb-3 list-inside list-disc space-y-1">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="text-card-foreground mb-3 list-inside list-decimal space-y-1">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-card-foreground">{children}</li>
),
a: ({ href, children }) => (
<a
href={href}
className="text-info hover:text-info/80 underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="text-card-foreground font-semibold">
{children}
</strong>
),
em: ({ children }) => (
<em className="text-card-foreground italic">
{children}
</em>
),
}}
>
{releaseInfo.body}
</ReactMarkdown>
</div>
</div>
) : (
<div className="border-border bg-card rounded-lg border p-6">
<p className="text-muted-foreground">
No changelog available for this release.
</p>
</div>
)}
{/* Warning */}
<div className="bg-warning/10 border-warning/30 rounded-lg border p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="text-warning mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="text-card-foreground text-sm">
<p className="mb-1 font-medium">Important:</p>
<p className="text-muted-foreground">
Please review the changelog above for any breaking changes
or important updates before proceeding. The server will
restart automatically after the update completes.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="border-border bg-muted/30 flex items-center justify-between border-t p-6">
<Button onClick={onClose} variant="ghost">
Cancel
</Button>
<Button onClick={onConfirm} variant="destructive" className="gap-2">
<span>Proceed with Update</span>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -4,9 +4,10 @@ import { api } from "~/trpc/react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { UpdateConfirmationModal } from "./UpdateConfirmationModal";
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
interface VersionDisplayProps {
onOpenReleaseNotes?: () => void;
@@ -85,55 +86,233 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
const [shouldSubscribe, setShouldSubscribe] = useState(false);
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
const lastLogTimeRef = useRef<number>(Date.now());
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
const lastLogTimeRef = useRef<number>(0);
// Initialize lastLogTimeRef in useEffect to avoid calling Date.now() during render
useEffect(() => {
if (lastLogTimeRef.current === 0) {
lastLogTimeRef.current = Date.now();
}
}, []);
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasReloadedRef = useRef<boolean>(false);
const isUpdatingRef = useRef<boolean>(false);
const isNetworkErrorRef = useRef<boolean>(false);
const updateSessionIdRef = useRef<string | null>(null);
const updateStartTimeRef = useRef<number | null>(null);
const logFileModifiedTimeRef = useRef<number | null>(null);
const isCompleteProcessedRef = useRef<boolean>(false);
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...']);
// Start subscribing to update logs only if we're actually updating
if (isUpdatingRef.current) {
setShouldSubscribe(true);
setUpdateLogs(['Update started...']);
}
} else {
setIsUpdating(false);
setShouldSubscribe(false); // Reset subscription on failure
updateSessionIdRef.current = null;
updateStartTimeRef.current = null;
logFileModifiedTimeRef.current = null;
isCompleteProcessedRef.current = false;
}
},
onError: (error) => {
setUpdateResult({ success: false, message: error.message });
setIsUpdating(false);
setShouldSubscribe(false); // Reset subscription on error
updateSessionIdRef.current = null;
updateStartTimeRef.current = null;
logFileModifiedTimeRef.current = null;
isCompleteProcessedRef.current = false;
}
});
// Poll for update logs
// Poll for update logs - only enabled when shouldSubscribe is true AND we're updating
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
enabled: shouldSubscribe,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
enabled: shouldSubscribe && isUpdating,
refetchInterval: shouldSubscribe && isUpdating ? 1000 : false, // Poll every second only when updating
refetchIntervalInBackground: false, // Don't poll in background to prevent stale data
});
// Attempt to reconnect and reload page when server is back
// Memoized with useCallback to prevent recreation on every render
// Only depends on refs to avoid stale closures
const startReconnectAttempts = useCallback(() => {
// CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts
// Only start if we're actually updating and haven't already started
// Double-check isUpdating state and session validity to prevent false triggers from stale data
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
return;
}
// Validate session age before starting reconnection attempts
const sessionAge = Date.now() - updateStartTimeRef.current;
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
if (sessionAge > MAX_SESSION_AGE) {
// Session is stale, don't start reconnection
return;
}
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
reconnectIntervalRef.current = setInterval(() => {
void (async () => {
// Guard: Only proceed if we're still updating and in network error state
// Check refs directly to avoid stale closures
if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
// Clear interval if we're no longer updating
if (!isUpdatingRef.current && reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
return;
}
// Validate session is still valid
const currentSessionAge = Date.now() - updateStartTimeRef.current;
if (currentSessionAge > MAX_SESSION_AGE) {
// Session expired, stop reconnection attempts
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
return;
}
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) {
// Double-check we're still updating and session is valid before reloading
if (!isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
return;
}
// Final session validation
const finalSessionAge = Date.now() - updateStartTimeRef.current;
if (finalSessionAge > MAX_SESSION_AGE) {
return;
}
// Mark that we're about to reload to prevent multiple reloads
hasReloadedRef.current = true;
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
// Clear interval
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
// Clear any existing reload timeout
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Set reload timeout
reloadTimeoutRef.current = setTimeout(() => {
reloadTimeoutRef.current = null;
window.location.reload();
}, 1000);
}
} catch {
// Server still down, keep trying
}
})();
}, 2000);
}, []); // Empty deps - only uses refs which are stable
// Update logs when data changes
useEffect(() => {
// CRITICAL: Only process update logs if we're actually updating
// This prevents stale isComplete data from triggering reloads when not updating
if (!isUpdating || !updateStartTimeRef.current) {
return;
}
// CRITICAL: Validate session - only process logs from current update session
// Check that update started within last 30 minutes (reasonable window for update)
const sessionAge = Date.now() - updateStartTimeRef.current;
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
if (sessionAge > MAX_SESSION_AGE) {
// Session is stale, reset everything
setTimeout(() => {
setIsUpdating(false);
setShouldSubscribe(false);
}, 0);
updateSessionIdRef.current = null;
updateStartTimeRef.current = null;
logFileModifiedTimeRef.current = null;
isCompleteProcessedRef.current = false;
return;
}
if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
if (updateLogsData.isComplete) {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
setIsNetworkError(true);
if (updateLogsData.logFileModifiedTime !== null && logFileModifiedTimeRef.current !== null) {
if (updateLogsData.logFileModifiedTime < logFileModifiedTimeRef.current) {
return;
}
} else if (updateLogsData.logFileModifiedTime !== null && updateStartTimeRef.current) {
const timeDiff = updateLogsData.logFileModifiedTime - updateStartTimeRef.current;
if (timeDiff < -5000) {
}
logFileModifiedTimeRef.current = updateLogsData.logFileModifiedTime;
}
lastLogTimeRef.current = Date.now();
setTimeout(() => setUpdateLogs(updateLogsData.logs), 0);
if (
updateLogsData.isComplete &&
isUpdating &&
updateStartTimeRef.current &&
sessionAge < MAX_SESSION_AGE &&
!isCompleteProcessedRef.current
) {
// Mark as processed immediately to prevent multiple triggers
isCompleteProcessedRef.current = true;
// Stop polling immediately to prevent further stale data processing
setTimeout(() => setShouldSubscribe(false), 0);
setTimeout(() => {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
setIsNetworkError(true);
}, 0);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
setTimeout(() => startReconnectAttempts(), 0);
}
}
}, [updateLogsData]);
}, [updateLogsData, startReconnectAttempts, isUpdating]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
if (!shouldSubscribe) return;
// Early return: only run if we're actually updating
if (!shouldSubscribe || !isUpdating) return;
// Only use this as a fallback - the main trigger should be completion detection
const checkInterval = setInterval(() => {
// Check refs first to ensure we're still updating
if (!isUpdatingRef.current || hasReloadedRef.current) {
return;
}
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
// Only start reconnection if we've been updating for at least 3 minutes
@@ -141,7 +320,10 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
// Additional guard: check refs again before triggering and validate session
const sessionAge = updateStartTimeRef.current ? Date.now() - updateStartTimeRef.current : Infinity;
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current && updateStartTimeRef.current && sessionAge < MAX_SESSION_AGE) {
setIsNetworkError(true);
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
@@ -151,55 +333,130 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
}, [shouldSubscribe, isUpdating, updateStartTime, startReconnectAttempts]);
// 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
// Keep refs in sync with state
useEffect(() => {
isUpdatingRef.current = isUpdating;
// CRITICAL: Reset shouldSubscribe immediately when isUpdating becomes false
// This prevents stale polling from continuing
if (!isUpdating) {
setTimeout(() => {
setShouldSubscribe(false);
}, 0);
// Reset completion processing flag when update stops
isCompleteProcessedRef.current = false;
}
}, [isUpdating]);
useEffect(() => {
isNetworkErrorRef.current = isNetworkError;
}, [isNetworkError]);
// Keep updateStartTime ref in sync
useEffect(() => {
updateStartTimeRef.current = updateStartTime;
}, [updateStartTime]);
// Clear reconnect interval when update completes or component unmounts
useEffect(() => {
// If we're no longer updating, clear the reconnect interval and reset subscription
if (!isUpdating) {
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
// Clear reload timeout if update stops
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Reset subscription to prevent stale polling
setTimeout(() => {
setShouldSubscribe(false);
}, 0);
// Reset completion processing flag
isCompleteProcessedRef.current = false;
// Don't clear session refs here - they're cleared explicitly on unmount or new update
}
return () => {
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
};
}, [isUpdating]);
// Cleanup on component unmount - reset all update-related state
useEffect(() => {
return () => {
// Clear all intervals
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
// Reset all refs and state
updateSessionIdRef.current = null;
updateStartTimeRef.current = null;
logFileModifiedTimeRef.current = null;
isCompleteProcessedRef.current = false;
hasReloadedRef.current = false;
isUpdatingRef.current = false;
isNetworkErrorRef.current = false;
};
}, []);
const handleUpdate = () => {
// Show confirmation modal instead of starting update directly
setShowUpdateConfirmation(true);
};
// Helper to generate secure random string
function getSecureRandomString(length: number): string {
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
// Convert to base36 string (alphanumeric)
return Array.from(array, b => b.toString(36)).join('').substr(0, length);
}
const handleConfirmUpdate = () => {
// Close the confirmation modal
setShowUpdateConfirmation(false);
// Start the actual update process
const randomSuffix = getSecureRandomString(9);
const sessionId = `update_${Date.now()}_${randomSuffix}`;
const startTime = Date.now();
setIsUpdating(true);
setUpdateResult(null);
setIsNetworkError(false);
setUpdateLogs([]);
setShouldSubscribe(false);
setUpdateStartTime(Date.now());
lastLogTimeRef.current = Date.now();
setShouldSubscribe(false); // Will be set to true in mutation onSuccess
setUpdateStartTime(startTime);
// Set refs for session tracking
updateSessionIdRef.current = sessionId;
updateStartTimeRef.current = startTime;
lastLogTimeRef.current = startTime;
logFileModifiedTimeRef.current = null; // Will be set when we first see log file
isCompleteProcessedRef.current = false; // Reset completion flag
hasReloadedRef.current = false; // Reset reload flag when starting new update
// Clear any existing reconnect interval and reload timeout
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
executeUpdate.mutate();
};
@@ -233,6 +490,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
{/* Loading overlay */}
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
{/* Update Confirmation Modal */}
{versionStatus?.releaseInfo && (
<UpdateConfirmationModal
isOpen={showUpdateConfirmation}
onClose={() => setShowUpdateConfirmation(false)}
onConfirm={handleConfirmUpdate}
releaseInfo={versionStatus.releaseInfo}
currentVersion={versionStatus.currentVersion}
latestVersion={versionStatus.latestVersion}
/>
)}
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
<Badge
variant={isUpToDate ? "default" : "secondary"}

View File

@@ -6,30 +6,40 @@ export interface ToggleProps
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
label?: string;
labelPosition?: 'left' | 'right';
}
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
({ className, checked, onCheckedChange, label, ...props }, ref) => {
({ className, checked, onCheckedChange, label, labelPosition = 'right', ...props }, ref) => {
const toggleSwitch = (
<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 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary/20 rounded-full peer after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out after:shadow-md transition-colors duration-300 ease-in-out border-2 border-gray-300 dark:border-gray-600",
checked
? "bg-blue-500 dark:bg-blue-600 after:translate-x-full"
: "bg-gray-300 dark:bg-gray-700",
className
)} />
</label>
);
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 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary/20 rounded-full peer after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out after:shadow-md transition-colors duration-300 ease-in-out border-2 border-gray-300 dark:border-gray-600",
checked
? "bg-blue-500 dark:bg-blue-600 after:translate-x-full"
: "bg-gray-300 dark:bg-gray-700",
className
)} />
</label>
{label && (
{label && labelPosition === 'left' && (
<span className="text-sm font-medium text-foreground">
{label}
</span>
)}
{toggleSwitch}
{label && labelPosition === 'right' && (
<span className="text-sm font-medium text-foreground">
{label}
</span>

View File

@@ -41,10 +41,14 @@ export async function POST(request: NextRequest) {
const sessionDurationDays = authConfig.sessionDurationDays;
const token = generateToken(username, sessionDurationDays);
// Calculate expiration time for client
const expirationTime = Date.now() + (sessionDurationDays * 24 * 60 * 60 * 1000);
const response = NextResponse.json({
success: true,
message: 'Login successful',
username
username,
expirationTime
});
// Determine if request is over HTTPS
@@ -54,7 +58,7 @@ export async function POST(request: NextRequest) {
response.cookies.set('auth-token', token, {
httpOnly: true,
secure: isSecure, // Only secure if actually over HTTPS
sameSite: 'strict',
sameSite: 'lax', // Use lax for cross-origin navigation support
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
path: '/',
});

View File

@@ -0,0 +1,96 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database-prisma';
import { getSSHExecutionService } from '../../../../../server/ssh-execution-service';
import type { Server } from '~/types/server';
const DISCOVER_TIMEOUT_MS = 10_000;
/** Match lines that look like SSH public keys (same as build.func) */
const SSH_PUBKEY_RE = /^(ssh-(rsa|ed25519)|ecdsa-sha2-nistp256|sk-(ssh-ed25519|ecdsa-sha2-nistp256))\s+/;
/**
* Run a command on the Proxmox host and return buffered stdout.
* Resolves when the process exits or rejects on timeout/spawn error.
*/
function runRemoteCommand(
server: Server,
command: string,
timeoutMs: number
): Promise<{ stdout: string; exitCode: number }> {
const ssh = getSSHExecutionService();
return new Promise((resolve, reject) => {
const chunks: string[] = [];
let settled = false;
const finish = (stdout: string, exitCode: number) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ stdout, exitCode });
};
const timer = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error('SSH discover keys timeout'));
}, timeoutMs);
ssh
.executeCommand(
server,
command,
(data: string) => chunks.push(data),
() => {},
(code: number) => finish(chunks.join(''), code)
)
.catch((err) => {
if (!settled) {
settled = true;
clearTimeout(timer);
reject(err);
}
});
});
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 });
}
const db = getDatabase();
const server = await db.getServerById(id) as Server | null;
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 });
}
// Same paths as native build.func ssh_discover_default_files()
const remoteScript = `bash -c 'for f in /root/.ssh/authorized_keys /root/.ssh/authorized_keys2 /root/.ssh/*.pub /etc/ssh/authorized_keys /etc/ssh/authorized_keys.d/* 2>/dev/null; do [ -f "$f" ] && [ -r "$f" ] && grep -E "^(ssh-(rsa|ed25519)|ecdsa-sha2-nistp256|sk-)" "$f" 2>/dev/null; done | sort -u'`;
const { stdout } = await runRemoteCommand(server, remoteScript, DISCOVER_TIMEOUT_MS);
const keys = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && SSH_PUBKEY_RE.test(line));
return NextResponse.json({ keys });
} catch (error) {
console.error('Error discovering SSH keys:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -3,6 +3,14 @@ import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database-prisma';
import { getSSHService } from '../../../../../server/ssh-service';
interface ServerData {
id: number;
name: string;
ip: string;
ssh_key_path?: string | null;
key_generated?: boolean;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
@@ -18,7 +26,7 @@ export async function GET(
}
const db = getDatabase();
const server = await db.getServerById(id);
const server = await db.getServerById(id) as ServerData | null;
if (!server) {
return NextResponse.json(
@@ -28,14 +36,14 @@ export async function GET(
}
// Only allow viewing public key if it was generated by the system
if (!(server as any).key_generated) {
if (!server.key_generated) {
return NextResponse.json(
{ error: 'Public key not available for user-provided keys' },
{ status: 403 }
);
}
if (!(server as any).ssh_key_path) {
if (!server.ssh_key_path) {
return NextResponse.json(
{ error: 'SSH key path not found' },
{ status: 404 }
@@ -43,13 +51,13 @@ export async function GET(
}
const sshService = getSSHService();
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
const publicKey = sshService.getPublicKey(server.ssh_key_path);
return NextResponse.json({
success: true,
publicKey,
serverName: (server as any).name,
serverIp: (server as any).ip
serverName: server.name,
serverIp: server.ip
});
} catch (error) {
console.error('Error retrieving public key:', error);

View File

@@ -12,7 +12,7 @@ export const POST = withApiLogging(async function POST(_request: NextRequest) {
// Get the next available server ID for key file naming
const serverId = await db.getNextServerId();
const keyPair = await sshService.generateKeyPair(serverId);
const keyPair = await sshService.generateKeyPair(Number(serverId));
return NextResponse.json({
success: true,

View File

@@ -4,9 +4,25 @@ import fs from 'fs';
import path from 'path';
import { isValidCron } from 'cron-validator';
interface AutoSyncSettings {
autoSyncEnabled: boolean;
syncIntervalType: string;
syncIntervalPredefined?: string;
syncIntervalCron?: string;
autoDownloadNew: boolean;
autoUpdateExisting: boolean;
notificationEnabled: boolean;
appriseUrls?: string[] | string;
lastAutoSync?: string;
lastAutoSyncError?: string;
lastAutoSyncErrorTime?: string;
testNotification?: boolean;
triggerManualSync?: boolean;
}
export async function POST(request: NextRequest) {
try {
const settings = await request.json();
const settings = await request.json() as AutoSyncSettings;
if (!settings || typeof settings !== 'object') {
return NextResponse.json(
@@ -54,7 +70,7 @@ export async function POST(request: NextRequest) {
// Validate predefined interval
if (settings.syncIntervalType === 'predefined') {
const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours'];
if (!validIntervals.includes(settings.syncIntervalPredefined)) {
if (!settings.syncIntervalPredefined || !validIntervals.includes(settings.syncIntervalPredefined)) {
return NextResponse.json(
{ error: 'Invalid predefined interval' },
{ status: 400 }
@@ -67,7 +83,7 @@ export async function POST(request: NextRequest) {
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') {
// Fallback to predefined if custom is selected but no cron expression
settings.syncIntervalType = 'predefined';
settings.syncIntervalPredefined = settings.syncIntervalPredefined || '1hour';
settings.syncIntervalPredefined = settings.syncIntervalPredefined ?? '1hour';
settings.syncIntervalCron = '';
} else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
return NextResponse.json(
@@ -109,7 +125,7 @@ export async function POST(request: NextRequest) {
);
}
}
} catch (parseError) {
} catch {
return NextResponse.json(
{ error: 'Invalid JSON format for Apprise URLs' },
{ status: 400 }
@@ -130,15 +146,15 @@ export async function POST(request: NextRequest) {
const autoSyncSettings = {
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false',
'SYNC_INTERVAL_TYPE': settings.syncIntervalType,
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined || '',
'SYNC_INTERVAL_CRON': settings.syncIntervalCron || '',
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined ?? '',
'SYNC_INTERVAL_CRON': settings.syncIntervalCron ?? '',
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false',
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls || '[]'),
'LAST_AUTO_SYNC': settings.lastAutoSync || '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls ?? '[]'),
'LAST_AUTO_SYNC': settings.lastAutoSync ?? '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError ?? '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime ?? ''
};
// Update or add each setting
@@ -160,18 +176,27 @@ export async function POST(request: NextRequest) {
// Reschedule auto-sync service with new settings
try {
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit.js');
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit');
let autoSyncService = getAutoSyncService();
// If no global instance exists, create one
if (!autoSyncService) {
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
autoSyncService = new AutoSyncService();
setAutoSyncService(autoSyncService);
}
// Update the global service instance with new settings
autoSyncService.saveSettings(settings);
// Normalize appriseUrls to always be an array
const normalizedSettings = {
...settings,
appriseUrls: Array.isArray(settings.appriseUrls)
? settings.appriseUrls
: settings.appriseUrls
? [settings.appriseUrls]
: undefined
};
autoSyncService.saveSettings(normalizedSettings);
if (settings.autoSyncEnabled) {
autoSyncService.scheduleAutoSync();
@@ -180,7 +205,7 @@ export async function POST(request: NextRequest) {
// Ensure the service is completely stopped and won't restart
autoSyncService.isRunning = false;
// Also stop the global service instance if it exists
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit.js');
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit');
stopGlobalAutoSync();
}
} catch (error) {
@@ -231,21 +256,21 @@ export async function GET() {
autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true',
syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined',
syncIntervalPredefined: getEnvValue(envContent, 'SYNC_INTERVAL_PREDEFINED') || '1hour',
syncIntervalCron: getEnvValue(envContent, 'SYNC_INTERVAL_CRON') || '',
syncIntervalCron: getEnvValue(envContent, 'SYNC_INTERVAL_CRON') ?? '',
autoDownloadNew: getEnvValue(envContent, 'AUTO_DOWNLOAD_NEW') === 'true',
autoUpdateExisting: getEnvValue(envContent, 'AUTO_UPDATE_EXISTING') === 'true',
notificationEnabled: getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true',
appriseUrls: (() => {
try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
return JSON.parse(urlsValue);
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') ?? '[]';
return JSON.parse(urlsValue) as string[];
} catch {
return [];
}
})(),
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') || '',
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') || null,
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') || null
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') ?? '',
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') ?? null,
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') ?? null
};
return NextResponse.json({ settings });
@@ -275,8 +300,8 @@ async function handleTestNotification() {
const notificationEnabled = getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true';
const appriseUrls = (() => {
try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
return JSON.parse(urlsValue);
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') ?? '[]';
return JSON.parse(urlsValue) as string[];
} catch {
return [];
}
@@ -289,7 +314,7 @@ async function handleTestNotification() {
);
}
if (!appriseUrls || appriseUrls.length === 0) {
if (!appriseUrls?.length) {
return NextResponse.json(
{ error: 'No Apprise URLs configured' },
{ status: 400 }
@@ -297,7 +322,7 @@ async function handleTestNotification() {
}
// Send test notification using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.testNotification();
@@ -345,11 +370,11 @@ async function handleManualSync() {
}
// Trigger manual sync using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.executeAutoSync() as any;
const result = await autoSyncService.executeAutoSync() as { success: boolean; message?: string } | null;
if (result && result.success) {
if (result?.success) {
return NextResponse.json({
success: true,
message: 'Manual sync completed successfully',
@@ -357,7 +382,7 @@ async function handleManualSync() {
});
} else {
return NextResponse.json(
{ error: result.message },
{ error: result?.message ?? 'Unknown error' },
{ status: 500 }
);
}
@@ -376,7 +401,7 @@ function getEnvValue(envContent: string, key: string): string {
const regex = new RegExp(`^${key}="(.+)"$`, 'm');
let match = regex.exec(envContent);
if (match && match[1]) {
if (match?.[1]) {
let value = match[1];
// Remove extra quotes that might be around JSON values
if (value.startsWith('"') && value.endsWith('"')) {
@@ -388,7 +413,7 @@ function getEnvValue(envContent: string, key: string): string {
// Try to match without quotes (fallback)
const regexNoQuotes = new RegExp(`^${key}=([^\\s]*)$`, 'm');
match = regexNoQuotes.exec(envContent);
if (match && match[1]) {
if (match?.[1]) {
return match[1];
}

View File

@@ -1,51 +1,72 @@
"use client";
'use client';
import { useState, useRef, useEffect } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid';
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
import { BackupsTab } from './_components/BackupsTab';
import { ResyncButton } from './_components/ResyncButton';
import { Terminal } from './_components/Terminal';
import { ServerSettingsButton } from './_components/ServerSettingsButton';
import { SettingsButton } from './_components/SettingsButton';
import { HelpButton } from './_components/HelpButton';
import { VersionDisplay } from './_components/VersionDisplay';
import { ThemeToggle } from './_components/ThemeToggle';
import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer';
import { Package, HardDrive, FolderOpen, LogOut, Archive } from 'lucide-react';
import { api } from '~/trpc/react';
import { useAuth } from './_components/AuthProvider';
import { useState, useRef, useEffect } from "react";
import { ScriptsGrid } from "./_components/ScriptsGrid";
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
import { BackupsTab } from "./_components/BackupsTab";
import { ResyncButton } from "./_components/ResyncButton";
import { Terminal } from "./_components/Terminal";
import { ServerSettingsButton } from "./_components/ServerSettingsButton";
import { SettingsButton } from "./_components/SettingsButton";
import { HelpButton } from "./_components/HelpButton";
import { VersionDisplay } from "./_components/VersionDisplay";
import { ThemeToggle } from "./_components/ThemeToggle";
import { Button } from "./_components/ui/button";
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon";
import {
ReleaseNotesModal,
getLastSeenVersion,
} from "./_components/ReleaseNotesModal";
import { Footer } from "./_components/Footer";
import { Package, HardDrive, FolderOpen, LogOut, Archive } from "lucide-react";
import { api } from "~/trpc/react";
import { useAuth } from "./_components/AuthProvider";
import type { Server } from "~/types/server";
import type { ScriptCard } from "~/types/script";
export default function Home() {
const { isAuthenticated, logout } = useAuth();
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed' | 'backups'>(() => {
if (typeof window !== 'undefined') {
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed' | 'backups';
return savedTab || 'scripts';
const [runningScript, setRunningScript] = useState<{
path: string;
name: string;
mode?: "local" | "ssh";
server?: Server;
envVars?: Record<string, string | number | boolean>;
} | null>(null);
const [activeTab, setActiveTab] = useState<
"scripts" | "downloaded" | "installed" | "backups"
>(() => {
if (typeof window !== "undefined") {
const savedTab = localStorage.getItem("activeTab") as
| "scripts"
| "downloaded"
| "installed"
| "backups";
return savedTab || "scripts";
}
return 'scripts';
return "scripts";
});
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(
undefined,
);
const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: scriptCardsData } =
api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } =
api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } =
api.installedScripts.getAllInstalledScripts.useQuery();
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
const { data: versionData } = api.version.getCurrentVersion.useQuery();
// Save active tab to localStorage whenever it changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('activeTab', activeTab);
if (typeof window !== "undefined") {
localStorage.setItem("activeTab", activeTab);
}
}, [activeTab]);
@@ -54,9 +75,12 @@ export default function Home() {
if (versionData?.success && versionData.version) {
const currentVersion = versionData.version;
const lastSeenVersion = getLastSeenVersion();
// If we have a current version and either no last seen version or versions don't match
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
if (
currentVersion &&
(!lastSeenVersion || currentVersion !== lastSeenVersion)
) {
setHighlightVersion(currentVersion);
setReleaseNotesOpen(true);
}
@@ -77,11 +101,11 @@ export default function Home() {
const scriptCounts = {
available: (() => {
if (!scriptCardsData?.success) return 0;
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => {
const scriptMap = new Map<string, ScriptCard>();
scriptCardsData.cards?.forEach((script: ScriptCard) => {
if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
@@ -89,39 +113,83 @@ export default function Home() {
}
}
});
return scriptMap.size;
})(),
downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// Helper to normalize identifiers for robust matching
const normalizeId = (s?: string): string =>
(s ?? "")
.toLowerCase()
.replace(/\.(sh|bash|py|js|ts)$/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
// First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => {
const scriptMap = new Map<string, ScriptCard>();
scriptCardsData.cards?.forEach((script: ScriptCard) => {
if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
}
});
const deduplicatedGithubScripts = Array.from(scriptMap.values());
const localScripts = localScriptsData.scripts ?? [];
const localScripts = (localScriptsData.scripts ?? []) as Array<{
name?: string;
slug?: string;
}>;
// Count scripts that are both in deduplicated GitHub data and have local versions
return deduplicatedGithubScripts.filter(script => {
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
return deduplicatedGithubScripts.filter((script) => {
if (!script?.name) return false;
return localScripts.some(local => {
// Check if there's a corresponding local script
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();
// Primary: Exact slug-to-slug matching (most reliable)
if (local.slug && script.slug) {
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
return true;
}
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
if (
normalizeId(local.slug ?? undefined) ===
normalizeId(script.slug ?? undefined)
) {
return true;
}
}
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
const normalizedLocal = normalizeId(local.name ?? undefined);
const matchesInstallBasename =
script.install_basenames?.some(
(base) => normalizeId(String(base)) === normalizedLocal,
) ?? false;
if (matchesInstallBasename) return true;
// Tertiary: Normalized filename to normalized slug matching
if (
script.slug &&
normalizeId(local.name ?? undefined) ===
normalizeId(script.slug ?? undefined)
) {
return true;
}
return false;
});
}).length;
})(),
installed: installedScriptsData?.scripts?.length ?? 0,
backups: backupsData?.success ? backupsData.backups.length : 0
backups: backupsData?.success ? backupsData.backups.length : 0,
};
const scrollToTerminal = () => {
@@ -129,16 +197,22 @@ export default function Home() {
// 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'
behavior: "smooth",
});
}
};
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
const handleRunScript = (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: Server,
envVars?: Record<string, string | number | boolean>,
) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server, envVars });
// Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100);
};
@@ -148,16 +222,16 @@ export default function Home() {
};
return (
<main className="min-h-screen bg-background">
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
<main className="bg-background min-h-screen">
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8">
{/* Header */}
<div className="text-center mb-6 sm:mb-8">
<div className="flex justify-between items-start mb-2">
<div className="mb-6 text-center sm:mb-8">
<div className="mb-2 flex items-start justify-between">
<div className="flex-1"></div>
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
<h1 className="text-foreground flex flex-1 items-center justify-center gap-2 text-2xl font-bold sm:gap-3 sm:text-3xl lg:text-4xl">
<span className="break-words">PVE Scripts Management</span>
</h1>
<div className="flex-1 flex justify-end items-center gap-2">
<div className="flex flex-1 items-center justify-end gap-2">
{isAuthenticated && (
<Button
variant="ghost"
@@ -173,8 +247,9 @@ export default function Home() {
<ThemeToggle />
</div>
</div>
<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 className="text-muted-foreground mb-4 px-2 text-sm sm:text-base">
Manage and execute Proxmox helper scripts locally with live output
streaming
</p>
<div className="flex justify-center px-2">
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
@@ -183,7 +258,7 @@ export default function Home() {
{/* Controls */}
<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">
<div className="bg-card border-border flex flex-col gap-4 rounded-lg border p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center sm:p-6">
<ServerSettingsButton />
<SettingsButton />
<ResyncButton />
@@ -193,72 +268,85 @@ export default function Home() {
{/* Tab Navigation */}
<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-1">
<div className="border-border border-b">
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'scripts'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
onClick={() => setActiveTab("scripts")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "scripts"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<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">
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.available}
</span>
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
<ContextualHelpIcon
section="available-scripts"
tooltip="Help with Available Scripts"
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('downloaded')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'downloaded'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
onClick={() => setActiveTab("downloaded")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "downloaded"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.downloaded}
</span>
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
<ContextualHelpIcon
section="downloaded-scripts"
tooltip="Help with Downloaded Scripts"
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'installed'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
onClick={() => setActiveTab("installed")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "installed"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<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">
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.installed}
</span>
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
<ContextualHelpIcon
section="installed-scripts"
tooltip="Help with Installed Scripts"
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('backups')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'backups'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
onClick={() => setActiveTab("backups")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "backups"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<Archive className="h-4 w-4" />
<span className="hidden sm:inline">Backups</span>
<span className="sm:hidden">Backups</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.backups}
</span>
</Button>
@@ -266,8 +354,6 @@ export default function Home() {
</div>
</div>
{/* Running Script Terminal */}
{runningScript && (
<div ref={terminalRef} className="mb-8">
@@ -276,26 +362,23 @@ export default function Home() {
onClose={handleCloseTerminal}
mode={runningScript.mode}
server={runningScript.server}
envVars={runningScript.envVars}
/>
</div>
)}
{/* Tab Content */}
{activeTab === 'scripts' && (
{activeTab === "scripts" && (
<ScriptsGrid onInstallScript={handleRunScript} />
)}
{activeTab === 'downloaded' && (
{activeTab === "downloaded" && (
<DownloadedScriptsTab onInstallScript={handleRunScript} />
)}
{activeTab === 'installed' && (
<InstalledScriptsTab />
)}
{activeTab === 'backups' && (
<BackupsTab />
)}
{activeTab === "installed" && <InstalledScriptsTab />}
{activeTab === "backups" && <BackupsTab />}
</div>
{/* Footer */}

View File

@@ -23,8 +23,11 @@ export const env = createEnv({
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
// WebSocket Configuration
WEBSOCKET_PORT: z.string().default("3001"),
// GitHub Configuration
// Git provider tokens (optional, for private repos)
GITHUB_TOKEN: z.string().optional(),
GITLAB_TOKEN: z.string().optional(),
BITBUCKET_APP_PASSWORD: z.string().optional(),
BITBUCKET_TOKEN: z.string().optional(),
// Authentication Configuration
AUTH_USERNAME: z.string().optional(),
AUTH_PASSWORD_HASH: z.string().optional(),
@@ -62,8 +65,10 @@ 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,
GITLAB_TOKEN: process.env.GITLAB_TOKEN,
BITBUCKET_APP_PASSWORD: process.env.BITBUCKET_APP_PASSWORD,
BITBUCKET_TOKEN: process.env.BITBUCKET_TOKEN,
// Authentication Configuration
AUTH_USERNAME: process.env.AUTH_USERNAME,
AUTH_PASSWORD_HASH: process.env.AUTH_PASSWORD_HASH,

View File

@@ -147,7 +147,7 @@ export function getAuthConfig(): {
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
const sessionDurationMatch = sessionDurationRegex.exec(envContent);
const sessionDurationDays = sessionDurationMatch
? parseInt(sessionDurationMatch[1]?.trim() || String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
? parseInt(sessionDurationMatch[1]?.trim() ?? String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
: DEFAULT_JWT_EXPIRY_DAYS;
const hasCredentials = !!(username && passwordHash);

View File

@@ -29,6 +29,7 @@ export const backupsRouter = createTRPCRouter({
storage_name: string;
storage_type: string;
discovered_at: Date;
server_id?: number;
server_name: string | null;
server_color: string | null;
}>;
@@ -38,7 +39,7 @@ export const backupsRouter = createTRPCRouter({
if (backups.length === 0) continue;
// Get hostname from first backup (all backups for same container should have same hostname)
const hostname = backups[0]?.hostname || '';
const hostname = backups[0]?.hostname ?? '';
result.push({
container_id: containerId,

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
return {
success: true,
credentials: credentials.map(c => ({
credentials: credentials.map((c: { id: number; server_id: number; storage_name: string; pbs_ip: string; pbs_datastore: string; pbs_fingerprint: string; pbs_password: string }) => ({
id: c.id,
server_id: c.server_id,
storage_name: c.storage_name,
@@ -109,7 +109,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
storage_name: input.storageName,
pbs_ip: input.pbs_ip,
pbs_datastore: input.pbs_datastore,
pbs_password: passwordToSave,
pbs_password: passwordToSave ?? '',
pbs_fingerprint: input.pbs_fingerprint,
});

View File

@@ -1,3 +1,4 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { scriptManager } from "~/server/lib/scripts";
@@ -6,7 +7,10 @@ import { localScriptsService } from "~/server/services/localScripts";
import { scriptDownloaderService } from "~/server/services/scriptDownloader.js";
import { AutoSyncService } from "~/server/services/autoSyncService";
import { repositoryService } from "~/server/services/repositoryService";
import { getStorageService } from "~/server/services/storageService";
import { getDatabase } from "~/server/database-prisma";
import type { ScriptCard } from "~/types/script";
import type { Server } from "~/types/server";
export const scriptsRouter = createTRPCRouter({
// Get all available scripts
@@ -100,7 +104,7 @@ export const scriptsRouter = createTRPCRouter({
getAllScripts: publicProcedure
.query(async () => {
try {
const scripts = await githubJsonService.getAllScripts();
const scripts = await localScriptsService.getAllScripts();
return { success: true, scripts };
} catch (error) {
return {
@@ -177,7 +181,7 @@ export const scriptsRouter = createTRPCRouter({
const scripts = await localScriptsService.getAllScripts();
// Create a set of enabled repository URLs for fast lookup
const enabledRepoUrls = new Set(enabledRepos.map(repo => repo.url));
const enabledRepoUrls = new Set(enabledRepos.map((repo: { url: string }) => repo.url));
// Create category ID to name mapping
const categoryMap: Record<number, string> = {};
@@ -188,7 +192,7 @@ export const scriptsRouter = createTRPCRouter({
}
// Enhance cards with category information and additional script data
const cardsWithCategories = cards.map(card => {
const cardsWithCategories = cards.map((card: ScriptCard) => {
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') ?? [];
@@ -225,7 +229,7 @@ export const scriptsRouter = createTRPCRouter({
// Filter cards to only include scripts from enabled repositories
// For backward compatibility, include scripts without repository_url
const filteredCards = cardsWithCategories.filter(card => {
const filteredCards = cardsWithCategories.filter((card: ScriptCard) => {
const repoUrl = card.repository_url;
// If script has no repository_url, include it for backward compatibility
@@ -636,5 +640,194 @@ export const scriptsRouter = createTRPCRouter({
status: null
};
}
}),
// Get rootfs storages for a server (for container creation)
getRootfsStorages: publicProcedure
.input(z.object({
serverId: z.number(),
forceRefresh: z.boolean().optional().default(false)
}))
.query(async ({ input }) => {
try {
const db = getDatabase();
const server = await db.getServerById(input.serverId);
if (!server) {
return {
success: false,
error: 'Server not found',
storages: []
};
}
// Get server hostname to filter storages by node assignment
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
const sshExecutionService = getSSHExecutionService();
let serverHostname = '';
try {
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server as Server,
'hostname',
(data: string) => {
serverHostname += data;
},
(error: string) => {
reject(new Error(`Failed to get hostname: ${error}`));
},
(exitCode: number) => {
if (exitCode === 0) {
resolve();
} else {
reject(new Error(`hostname command failed with exit code ${exitCode}`));
}
}
);
});
} catch (error) {
console.error('Error getting server hostname:', error);
// Continue without filtering if hostname can't be retrieved
}
const normalizedHostname = serverHostname.trim().toLowerCase();
const storageService = getStorageService();
const allStorages = await storageService.getStorages(server as Server, input.forceRefresh);
// Filter storages by node hostname matching and content type (rootdir for containers)
const rootfsStorages = allStorages.filter(storage => {
// Check content type - must have rootdir for containers
const hasRootdir = storage.content.includes('rootdir');
if (!hasRootdir) {
return false;
}
// If storage has no nodes specified, it's available on all nodes
if (!storage.nodes || storage.nodes.length === 0) {
return true;
}
// If we couldn't get hostname, include all storages (fallback)
if (!normalizedHostname) {
return true;
}
// Check if server hostname is in the nodes array (case-insensitive, trimmed)
const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase());
return normalizedNodes.includes(normalizedHostname);
});
return {
success: true,
storages: rootfsStorages.map(s => ({
name: s.name,
type: s.type,
content: s.content
}))
};
} catch (error) {
console.error('Error fetching rootfs storages:', error);
// Return empty array on error (as per plan requirement)
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch storages',
storages: []
};
}
}),
// Get template storages for a server (for template storage selection)
getTemplateStorages: publicProcedure
.input(z.object({
serverId: z.number(),
forceRefresh: z.boolean().optional().default(false)
}))
.query(async ({ input }) => {
try {
const db = getDatabase();
const server = await db.getServerById(input.serverId);
if (!server) {
return {
success: false,
error: 'Server not found',
storages: []
};
}
// Get server hostname to filter storages by node assignment
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
const sshExecutionService = getSSHExecutionService();
let serverHostname = '';
try {
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server as Server,
'hostname',
(data: string) => {
serverHostname += data;
},
(error: string) => {
reject(new Error(`Failed to get hostname: ${error}`));
},
(exitCode: number) => {
if (exitCode === 0) {
resolve();
} else {
reject(new Error(`hostname command failed with exit code ${exitCode}`));
}
}
);
});
} catch (error) {
console.error('Error getting server hostname:', error);
// Continue without filtering if hostname can't be retrieved
}
const normalizedHostname = serverHostname.trim().toLowerCase();
const storageService = getStorageService();
const allStorages = await storageService.getStorages(server as Server, input.forceRefresh);
// Filter storages by node hostname matching and content type (vztmpl for templates)
const templateStorages = allStorages.filter(storage => {
// Check content type - must have vztmpl for templates
const hasVztmpl = storage.content.includes('vztmpl');
if (!hasVztmpl) {
return false;
}
// If storage has no nodes specified, it's available on all nodes
if (!storage.nodes || storage.nodes.length === 0) {
return true;
}
// If we couldn't get hostname, include all storages (fallback)
if (!normalizedHostname) {
return true;
}
// Check if server hostname is in the nodes array (case-insensitive, trimmed)
const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase());
return normalizedNodes.includes(normalizedHostname);
});
return {
success: true,
storages: templateStorages.map(s => ({
name: s.name,
type: s.type,
content: s.content
}))
};
} catch (error) {
console.error('Error fetching template storages:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch storages',
storages: []
};
}
})
});

View File

@@ -1,5 +1,5 @@
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { readFile, writeFile } from "fs/promises";
import { readFile, writeFile, stat } from "fs/promises";
import { join } from "path";
import { spawn } from "child_process";
import { env } from "~/env";
@@ -111,7 +111,8 @@ export const versionRouter = createTRPCRouter({
tagName: release.tag_name,
name: release.name,
publishedAt: release.published_at,
htmlUrl: release.html_url
htmlUrl: release.html_url,
body: release.body
}
};
} catch (error) {
@@ -175,10 +176,21 @@ export const versionRouter = createTRPCRouter({
return {
success: true,
logs: [],
isComplete: false
isComplete: false,
logFileModifiedTime: null
};
}
// Get log file modification time for session validation
let logFileModifiedTime: number | null = null;
try {
const stats = await stat(logPath);
logFileModifiedTime = stats.mtimeMs;
} catch (statError) {
// If we can't get stats, continue without timestamp
console.warn('Could not get log file stats:', statError);
}
const logs = await readFile(logPath, 'utf-8');
const logLines = logs.split('\n')
.filter(line => line.trim())
@@ -201,7 +213,8 @@ export const versionRouter = createTRPCRouter({
return {
success: true,
logs: logLines,
isComplete
isComplete,
logFileModifiedTime
};
} catch (error) {
console.error('Error reading update logs:', error);
@@ -209,7 +222,8 @@ export const versionRouter = createTRPCRouter({
success: false,
error: error instanceof Error ? error.message : 'Failed to read update logs',
logs: [],
isComplete: false
isComplete: false,
logFileModifiedTime: null
};
}
}),
@@ -224,6 +238,27 @@ export const versionRouter = createTRPCRouter({
// Clear/create the log file
await writeFile(logPath, '', 'utf-8');
// Always fetch the latest update.sh from GitHub before running
// This ensures we always use the newest update script, avoiding
// the "chicken-and-egg" problem where old scripts can't update properly
const updateScriptUrl = 'https://raw.githubusercontent.com/community-scripts/ProxmoxVE-Local/main/update.sh';
try {
const response = await fetch(updateScriptUrl);
if (response.ok) {
const latestScript = await response.text();
await writeFile(updateScriptPath, latestScript, { mode: 0o755 });
// Log that we fetched the latest script
await writeFile(logPath, '[INFO] Fetched latest update.sh from GitHub\n', { flag: 'a' });
} else {
// If fetch fails, log warning but continue with local script
await writeFile(logPath, `[WARNING] Could not fetch latest update.sh (HTTP ${response.status}), using local version\n`, { flag: 'a' });
}
} catch (fetchError) {
// If fetch fails, log warning but continue with local script
const errorMsg = fetchError instanceof Error ? fetchError.message : 'Unknown error';
await writeFile(logPath, `[WARNING] Could not fetch latest update.sh: ${errorMsg}, using local version\n`, { flag: 'a' });
}
// 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

View File

@@ -9,10 +9,10 @@ class DatabaseServicePrisma {
}
init() {
// Ensure data/ssh-keys directory exists
// Ensure data/ssh-keys directory exists (recursive to create parent dirs)
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { mode: 0o700 });
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 });
}
}

View File

@@ -3,26 +3,128 @@ import { join } from 'path';
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
import { existsSync } from 'fs';
import type { CreateServerData } from '../types/server';
import type { Prisma } from '../../prisma/generated/prisma/client';
// Type definitions based on Prisma schema
type Server = {
id: number;
name: string;
ip: string;
user: string;
password: string | null;
auth_type: string | null;
ssh_key: string | null;
ssh_key_passphrase: string | null;
ssh_port: number | null;
color: string | null;
created_at: Date | null;
updated_at: Date | null;
ssh_key_path: string | null;
key_generated: boolean | null;
};
type InstalledScript = {
id: number;
script_name: string;
script_path: string;
container_id: string | null;
server_id: number | null;
execution_mode: string;
installation_date: Date | null;
status: string;
output_log: string | null;
web_ui_ip: string | null;
web_ui_port: number | null;
};
type InstalledScriptWithServer = InstalledScript & {
server: Server | null;
};
type LXCConfig = {
id: number;
installed_script_id: number;
arch: string | null;
cores: number | null;
memory: number | null;
hostname: string | null;
swap: number | null;
onboot: number | null;
ostype: string | null;
unprivileged: number | null;
net_name: string | null;
net_bridge: string | null;
net_hwaddr: string | null;
net_ip_type: string | null;
net_ip: string | null;
net_gateway: string | null;
net_type: string | null;
net_vlan: number | null;
rootfs_storage: string | null;
rootfs_size: string | null;
feature_keyctl: number | null;
feature_nesting: number | null;
feature_fuse: number | null;
feature_mount: string | null;
tags: string | null;
advanced_config: string | null;
synced_at: Date | null;
config_hash: string | null;
created_at: Date;
updated_at: Date;
};
type Backup = {
id: number;
container_id: string;
server_id: number;
hostname: string;
backup_name: string;
backup_path: string;
size: bigint | null;
created_at: Date | null;
storage_name: string;
storage_type: string;
discovered_at: Date;
};
type BackupWithServer = Backup & {
server: Server | null;
};
type PBSStorageCredential = {
id: number;
server_id: number;
storage_name: string;
pbs_ip: string;
pbs_datastore: string;
pbs_password: string;
pbs_fingerprint: string;
created_at: Date;
updated_at: Date;
};
type LXCConfigInput = Partial<Omit<LXCConfig, 'id' | 'installed_script_id' | 'created_at' | 'updated_at'>>;
class DatabaseServicePrisma {
constructor() {
this.init();
}
init() {
// Ensure data/ssh-keys directory exists
init(): void {
// Ensure data/ssh-keys directory exists (recursive to create parent dirs)
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { mode: 0o700 });
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 });
}
}
// Server CRUD operations
async createServer(serverData: CreateServerData) {
async createServer(serverData: CreateServerData): Promise<Server> {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22;
let ssh_key_path = null;
let ssh_key_path: string | null = null;
// If using SSH key authentication, create persistent key file
if (auth_type === 'key' && ssh_key) {
@@ -30,7 +132,7 @@ class DatabaseServicePrisma {
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
}
return await prisma.server.create({
const result = await prisma.server.create({
data: {
name,
ip,
@@ -45,27 +147,30 @@ class DatabaseServicePrisma {
color,
}
});
return result as Server;
}
async getAllServers() {
return await prisma.server.findMany({
async getAllServers(): Promise<Server[]> {
const result = await prisma.server.findMany({
orderBy: { created_at: 'desc' }
});
return result as Server[];
}
async getServerById(id: number) {
return await prisma.server.findUnique({
async getServerById(id: number): Promise<Server | null> {
const result = await prisma.server.findUnique({
where: { id }
});
return result as Server | null;
}
async updateServer(id: number, serverData: CreateServerData) {
async updateServer(id: number, serverData: CreateServerData): Promise<Server> {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : undefined;
// Get existing server to check for key changes
const existingServer = await this.getServerById(id);
let ssh_key_path = existingServer?.ssh_key_path;
let ssh_key_path = existingServer?.ssh_key_path ?? null;
// Handle SSH key changes
if (auth_type === 'key' && ssh_key) {
@@ -101,7 +206,7 @@ class DatabaseServicePrisma {
ssh_key_path = null;
}
return await prisma.server.update({
const result = await prisma.server.update({
where: { id },
data: {
name,
@@ -117,9 +222,10 @@ class DatabaseServicePrisma {
color,
}
});
return result as Server;
}
async deleteServer(id: number) {
async deleteServer(id: number): Promise<Server> {
// Get server info before deletion to clean up key files
const server = await this.getServerById(id);
@@ -136,9 +242,10 @@ class DatabaseServicePrisma {
}
}
return await prisma.server.delete({
const result = await prisma.server.delete({
where: { id }
});
return result as Server;
}
// Installed Scripts CRUD operations
@@ -152,10 +259,10 @@ class DatabaseServicePrisma {
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
}) {
}): Promise<InstalledScript> {
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
return await prisma.installedScript.create({
const result = await prisma.installedScript.create({
data: {
script_name,
script_path,
@@ -168,34 +275,40 @@ class DatabaseServicePrisma {
web_ui_port: web_ui_port ?? null,
}
});
return result as InstalledScript;
}
async getAllInstalledScripts() {
return await prisma.installedScript.findMany({
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> {
const result = await prisma.installedScript.findMany({
include: {
server: true
server: true,
lxc_config: true
},
orderBy: { installation_date: 'desc' }
});
return result as InstalledScriptWithServer[];
}
async getInstalledScriptById(id: number) {
return await prisma.installedScript.findUnique({
async getInstalledScriptById(id: number): Promise<InstalledScriptWithServer | null> {
const result = await prisma.installedScript.findUnique({
where: { id },
include: {
server: true
}
});
return result as InstalledScriptWithServer | null;
}
async getInstalledScriptsByServer(server_id: number) {
return await prisma.installedScript.findMany({
async getInstalledScriptsByServer(server_id: number): Promise<InstalledScriptWithServer[]> {
const result = await prisma.installedScript.findMany({
where: { server_id },
include: {
server: true
server: true,
lxc_config: true
},
orderBy: { installation_date: 'desc' }
});
return result as InstalledScriptWithServer[];
}
async updateInstalledScript(id: number, updateData: {
@@ -205,17 +318,10 @@ class DatabaseServicePrisma {
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
}) {
}): Promise<InstalledScript | { changes: number }> {
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
const updateFields: {
script_name?: string;
container_id?: string;
status?: 'in_progress' | 'success' | 'failed';
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
} = {};
const updateFields: Prisma.InstalledScriptUpdateInput = {};
if (script_name !== undefined) updateFields.script_name = script_name;
if (container_id !== undefined) updateFields.container_id = container_id;
if (status !== undefined) updateFields.status = status;
@@ -227,33 +333,36 @@ class DatabaseServicePrisma {
return { changes: 0 };
}
return await prisma.installedScript.update({
const result = await prisma.installedScript.update({
where: { id },
data: updateFields
});
return result as InstalledScript;
}
async deleteInstalledScript(id: number) {
return await prisma.installedScript.delete({
async deleteInstalledScript(id: number): Promise<InstalledScript> {
const result = await prisma.installedScript.delete({
where: { id }
});
return result as InstalledScript;
}
async deleteInstalledScriptsByServer(server_id: number) {
return await prisma.installedScript.deleteMany({
async deleteInstalledScriptsByServer(server_id: number): Promise<{ count: number }> {
const result = await prisma.installedScript.deleteMany({
where: { server_id }
});
return result as { count: number };
}
async getNextServerId() {
async getNextServerId(): Promise<number> {
const result = await prisma.server.findFirst({
orderBy: { id: 'desc' },
select: { id: true }
});
return (result?.id ?? 0) + 1;
return ((result as { id: number } | null)?.id ?? 0) + 1;
}
createSSHKeyFile(serverId: number, sshKey: string) {
createSSHKeyFile(serverId: number, sshKey: string): string {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
@@ -266,17 +375,18 @@ class DatabaseServicePrisma {
}
// LXC Config CRUD operations
async createLXCConfig(scriptId: number, configData: any) {
return await prisma.lXCConfig.create({
async createLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> {
const result = await prisma.lXCConfig.create({
data: {
installed_script_id: scriptId,
...configData
}
});
return result as LXCConfig;
}
async updateLXCConfig(scriptId: number, configData: any) {
return await prisma.lXCConfig.upsert({
async updateLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> {
const result = await prisma.lXCConfig.upsert({
where: { installed_script_id: scriptId },
update: configData,
create: {
@@ -284,16 +394,18 @@ class DatabaseServicePrisma {
...configData
}
});
return result as LXCConfig;
}
async getLXCConfigByScriptId(scriptId: number) {
return await prisma.lXCConfig.findUnique({
async getLXCConfigByScriptId(scriptId: number): Promise<LXCConfig | null> {
const result = await prisma.lXCConfig.findUnique({
where: { installed_script_id: scriptId }
});
return result as LXCConfig | null;
}
async deleteLXCConfig(scriptId: number) {
return await prisma.lXCConfig.delete({
async deleteLXCConfig(scriptId: number): Promise<void> {
await prisma.lXCConfig.delete({
where: { installed_script_id: scriptId }
});
}
@@ -309,7 +421,7 @@ class DatabaseServicePrisma {
created_at?: Date;
storage_name: string;
storage_type: 'local' | 'storage' | 'pbs';
}) {
}): Promise<Backup> {
// Find existing backup by container_id, server_id, and backup_path
const existing = await prisma.backup.findFirst({
where: {
@@ -317,11 +429,11 @@ class DatabaseServicePrisma {
server_id: backupData.server_id,
backup_path: backupData.backup_path,
},
});
}) as Backup | null;
if (existing) {
// Update existing backup
return await prisma.backup.update({
const result = await prisma.backup.update({
where: { id: existing.id },
data: {
hostname: backupData.hostname,
@@ -333,9 +445,10 @@ class DatabaseServicePrisma {
discovered_at: new Date(),
},
});
return result as Backup;
} else {
// Create new backup
return await prisma.backup.create({
const result = await prisma.backup.create({
data: {
container_id: backupData.container_id,
server_id: backupData.server_id,
@@ -349,11 +462,12 @@ class DatabaseServicePrisma {
discovered_at: new Date(),
},
});
return result as Backup;
}
}
async getAllBackups() {
return await prisma.backup.findMany({
async getAllBackups(): Promise<BackupWithServer[]> {
const result = await prisma.backup.findMany({
include: {
server: true,
},
@@ -362,58 +476,43 @@ class DatabaseServicePrisma {
{ created_at: 'desc' },
],
});
return result as BackupWithServer[];
}
async getBackupById(id: number) {
return await prisma.backup.findUnique({
async getBackupById(id: number): Promise<BackupWithServer | null> {
const result = await prisma.backup.findUnique({
where: { id },
include: {
server: true,
},
});
return result as BackupWithServer | null;
}
async getBackupsByContainerId(containerId: string) {
return await prisma.backup.findMany({
async getBackupsByContainerId(containerId: string): Promise<BackupWithServer[]> {
const result = await prisma.backup.findMany({
where: { container_id: containerId },
include: {
server: true,
},
orderBy: { created_at: 'desc' },
});
return result as BackupWithServer[];
}
async deleteBackupsForContainer(containerId: string, serverId: number) {
return await prisma.backup.deleteMany({
async deleteBackupsForContainer(containerId: string, serverId: number): Promise<{ count: number }> {
const result = await prisma.backup.deleteMany({
where: {
container_id: containerId,
server_id: serverId,
},
});
return result as { count: number };
}
async getBackupsGroupedByContainer(): Promise<Map<string, Array<{
id: number;
container_id: string;
server_id: number;
hostname: string;
backup_name: string;
backup_path: string;
size: bigint | null;
created_at: Date | null;
storage_name: string;
storage_type: string;
discovered_at: Date;
server: {
id: number;
name: string;
ip: string;
user: string;
color: string | null;
} | null;
}>>> {
async getBackupsGroupedByContainer(): Promise<Map<string, BackupWithServer[]>> {
const backups = await this.getAllBackups();
const grouped = new Map<string, typeof backups>();
const grouped = new Map<string, BackupWithServer[]>();
for (const backup of backups) {
const key = backup.container_id;
@@ -434,8 +533,8 @@ class DatabaseServicePrisma {
pbs_datastore: string;
pbs_password: string;
pbs_fingerprint: string;
}) {
return await prisma.pBSStorageCredential.upsert({
}): Promise<PBSStorageCredential> {
const result = await prisma.pBSStorageCredential.upsert({
where: {
server_id_storage_name: {
server_id: credentialData.server_id,
@@ -458,10 +557,11 @@ class DatabaseServicePrisma {
pbs_fingerprint: credentialData.pbs_fingerprint,
},
});
return result as PBSStorageCredential;
}
async getPBSCredential(serverId: number, storageName: string) {
return await prisma.pBSStorageCredential.findUnique({
async getPBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential | null> {
const result = await prisma.pBSStorageCredential.findUnique({
where: {
server_id_storage_name: {
server_id: serverId,
@@ -469,17 +569,19 @@ class DatabaseServicePrisma {
},
},
});
return result as PBSStorageCredential | null;
}
async getPBSCredentialsByServer(serverId: number) {
return await prisma.pBSStorageCredential.findMany({
async getPBSCredentialsByServer(serverId: number): Promise<PBSStorageCredential[]> {
const result = await prisma.pBSStorageCredential.findMany({
where: { server_id: serverId },
orderBy: { storage_name: 'asc' },
});
return result as PBSStorageCredential[];
}
async deletePBSCredential(serverId: number, storageName: string) {
return await prisma.pBSStorageCredential.delete({
async deletePBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential> {
const result = await prisma.pBSStorageCredential.delete({
where: {
server_id_storage_name: {
server_id: serverId,
@@ -487,9 +589,10 @@ class DatabaseServicePrisma {
},
},
});
return result as PBSStorageCredential;
}
async close() {
async close(): Promise<void> {
await prisma.$disconnect();
}
}
@@ -497,7 +600,7 @@ class DatabaseServicePrisma {
// Singleton instance
let dbInstance: DatabaseServicePrisma | null = null;
export function getDatabase() {
export function getDatabase(): DatabaseServicePrisma {
dbInstance ??= new DatabaseServicePrisma();
return dbInstance;
}

View File

@@ -1,7 +1,24 @@
import { PrismaClient } from '@prisma/client';
import 'dotenv/config'
import { PrismaClient } from '../../prisma/generated/prisma/client.ts'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { existsSync, mkdirSync } from 'fs'
import { dirname } from 'path'
const globalForPrisma = globalThis;
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
// Ensure database directory exists before initializing Prisma
// DATABASE_URL format: file:/path/to/database.db
const dbUrl = process.env.DATABASE_URL || 'file:./data/settings.db';
const dbPath = dbUrl.replace(/^file:/, '');
const dbDir = dirname(dbPath);
if (!existsSync(dbDir)) {
console.log(`Creating database directory: ${dbDir}`);
mkdirSync(dbDir, { recursive: true });
}
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL });
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

View File

@@ -1,10 +1,26 @@
import { PrismaClient } from '@prisma/client';
import 'dotenv/config'
import { PrismaClient } from '../../prisma/generated/prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { existsSync, mkdirSync } from 'fs'
import { dirname } from 'path'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
const globalForPrisma = globalThis as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
// Ensure database directory exists before initializing Prisma
// DATABASE_URL format: file:/path/to/database.db
const dbUrl = process.env.DATABASE_URL || 'file:./data/settings.db';
const dbPath = dbUrl.replace(/^file:/, '');
const dbDir = dirname(dbPath);
if (!existsSync(dbDir)) {
console.log(`Creating database directory: ${dbDir}`);
mkdirSync(dbDir, { recursive: true });
}
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL! });
export const prisma: PrismaClient = globalForPrisma.prisma ?? new PrismaClient({
adapter,
log: ['warn', 'error']
});

View File

@@ -1,17 +1,23 @@
import { AutoSyncService } from '../services/autoSyncService.js';
import { repositoryService } from '../services/repositoryService.ts';
import { repositoryService } from '../services/repositoryService.js';
/** @type {AutoSyncService | null} */
let autoSyncService = null;
let isInitialized = false;
/**
* Initialize default repositories
* @returns {Promise<void>}
*/
export async function initializeRepositories() {
try {
console.log('Initializing default repositories...');
await repositoryService.initializeDefaultRepositories();
console.log('Default repositories initialized successfully');
if (repositoryService && repositoryService.initializeDefaultRepositories) {
await repositoryService.initializeDefaultRepositories();
console.log('Default repositories initialized successfully');
} else {
console.warn('Repository service not available, skipping repository initialization');
}
} catch (error) {
console.error('Failed to initialize repositories:', error);
console.error('Error stack:', error.stack);

View File

@@ -1,7 +1,22 @@
import { AutoSyncService } from '~/server/services/autoSyncService';
import { repositoryService } from '~/server/services/repositoryService';
let autoSyncService: AutoSyncService | null = null;
/**
* Initialize default repositories
*/
export async function initializeRepositories(): Promise<void> {
try {
console.log('Initializing default repositories...');
await repositoryService.initializeDefaultRepositories();
console.log('Default repositories initialized successfully');
} catch (error) {
console.error('Failed to initialize repositories:', error);
console.error('Error stack:', (error as Error).stack);
}
}
/**
* Initialize auto-sync service and schedule cron job if enabled
*/

View File

@@ -0,0 +1,55 @@
import type { DirEntry, GitProvider } from './types';
import { parseRepoUrl } from '../repositoryUrlValidation';
export class BitbucketProvider implements GitProvider {
async listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]> {
const { owner, repo } = parseRepoUrl(repoUrl);
const listUrl = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${encodeURIComponent(branch)}/${path}`;
const headers: Record<string, string> = {
'User-Agent': 'PVEScripts-Local/1.0',
};
const token = process.env.BITBUCKET_APP_PASSWORD ?? process.env.BITBUCKET_TOKEN;
if (token) {
const auth = Buffer.from(`:${token}`).toString('base64');
headers.Authorization = `Basic ${auth}`;
}
const response = await fetch(listUrl, { headers });
if (!response.ok) {
throw new Error(`Bitbucket API error: ${response.status} ${response.statusText}`);
}
const body = (await response.json()) as { values?: { path: string; type: string }[] };
const data = body.values ?? (Array.isArray(body) ? body : []);
if (!Array.isArray(data)) {
throw new Error('Bitbucket API returned unexpected response');
}
return data.map((item: { path: string; type: string }) => {
const name = item.path.split('/').pop() ?? item.path;
return {
name,
path: item.path,
type: item.type === 'commit_directory' ? ('dir' as const) : ('file' as const),
};
});
}
async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string> {
const { owner, repo } = parseRepoUrl(repoUrl);
const rawUrl = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${encodeURIComponent(branch)}/${filePath}`;
const headers: Record<string, string> = {
'User-Agent': 'PVEScripts-Local/1.0',
};
const token = process.env.BITBUCKET_APP_PASSWORD ?? process.env.BITBUCKET_TOKEN;
if (token) {
const auth = Buffer.from(`:${token}`).toString('base64');
headers.Authorization = `Basic ${auth}`;
}
const response = await fetch(rawUrl, { headers });
if (!response.ok) {
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
return response.text();
}
}

View File

@@ -0,0 +1,44 @@
import type { DirEntry, GitProvider } from "./types";
import { parseRepoUrl } from "../repositoryUrlValidation";
export class CustomProvider implements GitProvider {
async listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]> {
const { origin, owner, repo } = parseRepoUrl(repoUrl);
const apiUrl = `${origin}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}`;
const headers: Record<string, string> = { "User-Agent": "PVEScripts-Local/1.0" };
const token = process.env.GITEA_TOKEN ?? process.env.GIT_TOKEN;
if (token) headers.Authorization = `token ${token}`;
const response = await fetch(apiUrl, { headers });
if (!response.ok) {
throw new Error(`Custom Git server: list directory failed (${response.status}).`);
}
const data = (await response.json()) as { type: string; name: string; path: string }[];
if (!Array.isArray(data)) {
const single = data as unknown as { type?: string; name?: string; path?: string };
if (single?.name) {
return [{ name: single.name, path: single.path ?? path, type: single.type === "dir" ? "dir" : "file" }];
}
throw new Error("Custom Git server returned unexpected response");
}
return data.map((item) => ({
name: item.name,
path: item.path,
type: item.type === "dir" ? ("dir" as const) : ("file" as const),
}));
}
async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string> {
const { origin, owner, repo } = parseRepoUrl(repoUrl);
const rawUrl = `${origin}/${owner}/${repo}/raw/${encodeURIComponent(branch)}/${filePath}`;
const headers: Record<string, string> = { "User-Agent": "PVEScripts-Local/1.0" };
const token = process.env.GITEA_TOKEN ?? process.env.GIT_TOKEN;
if (token) headers.Authorization = `token ${token}`;
const response = await fetch(rawUrl, { headers });
if (!response.ok) {
throw new Error(`Failed to download ${filePath} from custom Git server (${response.status}).`);
}
return response.text();
}
}

View File

@@ -0,0 +1,60 @@
import type { DirEntry, GitProvider } from './types';
import { parseRepoUrl } from '../repositoryUrlValidation';
export class GitHubProvider implements GitProvider {
async listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]> {
const { owner, repo } = parseRepoUrl(repoUrl);
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}`;
const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
};
const token = process.env.GITHUB_TOKEN;
if (token) headers.Authorization = `token ${token}`;
const response = await fetch(apiUrl, { headers });
if (!response.ok) {
if (response.status === 403) {
const err = new Error(
`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN. Status: ${response.status} ${response.statusText}`
);
(err as Error & { name: string }).name = 'RateLimitError';
throw err;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as { type: string; name: string; path: string }[];
if (!Array.isArray(data)) {
throw new Error('GitHub API returned unexpected response');
}
return data.map((item) => ({
name: item.name,
path: item.path,
type: item.type === 'dir' ? ('dir' as const) : ('file' as const),
}));
}
async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string> {
const { owner, repo } = parseRepoUrl(repoUrl);
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(branch)}/${filePath}`;
const headers: Record<string, string> = {
'User-Agent': 'PVEScripts-Local/1.0',
};
const token = process.env.GITHUB_TOKEN;
if (token) headers.Authorization = `token ${token}`;
const response = await fetch(rawUrl, { headers });
if (!response.ok) {
if (response.status === 403) {
const err = new Error(
`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN.`
);
(err as Error & { name: string }).name = 'RateLimitError';
throw err;
}
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
return response.text();
}
}

View File

@@ -0,0 +1,58 @@
import type { DirEntry, GitProvider } from './types';
import { parseRepoUrl } from '../repositoryUrlValidation';
export class GitLabProvider implements GitProvider {
private getBaseUrl(repoUrl: string): string {
const { origin } = parseRepoUrl(repoUrl);
return origin;
}
private getProjectId(repoUrl: string): string {
const { owner, repo } = parseRepoUrl(repoUrl);
return encodeURIComponent(`${owner}/${repo}`);
}
async listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]> {
const baseUrl = this.getBaseUrl(repoUrl);
const projectId = this.getProjectId(repoUrl);
const apiUrl = `${baseUrl}/api/v4/projects/${projectId}/repository/tree?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(branch)}&per_page=100`;
const headers: Record<string, string> = {
'User-Agent': 'PVEScripts-Local/1.0',
};
const token = process.env.GITLAB_TOKEN;
if (token) headers['PRIVATE-TOKEN'] = token;
const response = await fetch(apiUrl, { headers });
if (!response.ok) {
throw new Error(`GitLab API error: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as { type: string; name: string; path: string }[];
if (!Array.isArray(data)) {
throw new Error('GitLab API returned unexpected response');
}
return data.map((item) => ({
name: item.name,
path: item.path,
type: item.type === 'tree' ? ('dir' as const) : ('file' as const),
}));
}
async downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string> {
const baseUrl = this.getBaseUrl(repoUrl);
const projectId = this.getProjectId(repoUrl);
const encodedPath = encodeURIComponent(filePath);
const rawUrl = `${baseUrl}/api/v4/projects/${projectId}/repository/files/${encodedPath}/raw?ref=${encodeURIComponent(branch)}`;
const headers: Record<string, string> = {
'User-Agent': 'PVEScripts-Local/1.0',
};
const token = process.env.GITLAB_TOKEN;
if (token) headers['PRIVATE-TOKEN'] = token;
const response = await fetch(rawUrl, { headers });
if (!response.ok) {
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
return response.text();
}
}

View File

@@ -0,0 +1 @@
export { listDirectory, downloadRawFile, getRepoProvider } from "./index.ts";

View File

@@ -0,0 +1,28 @@
import type { DirEntry, GitProvider } from "./types";
import { getRepoProvider } from "../repositoryUrlValidation";
import { GitHubProvider } from "./github";
import { GitLabProvider } from "./gitlab";
import { BitbucketProvider } from "./bitbucket";
import { CustomProvider } from "./custom";
const providers: Record<string, GitProvider> = {
github: new GitHubProvider(),
gitlab: new GitLabProvider(),
bitbucket: new BitbucketProvider(),
custom: new CustomProvider(),
};
export type { DirEntry, GitProvider };
export { getRepoProvider };
export function getGitProvider(repoUrl: string): GitProvider {
return providers[getRepoProvider(repoUrl)]!;
}
export async function listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]> {
return getGitProvider(repoUrl).listDirectory(repoUrl, path, branch);
}
export async function downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string> {
return getGitProvider(repoUrl).downloadRawFile(repoUrl, filePath, branch);
}

View File

@@ -0,0 +1,14 @@
/**
* Git provider interface for listing and downloading repository files.
*/
export type DirEntry = {
name: string;
path: string;
type: 'file' | 'dir';
};
export interface GitProvider {
listDirectory(repoUrl: string, path: string, branch: string): Promise<DirEntry[]>;
downloadRawFile(repoUrl: string, filePath: string, branch: string): Promise<string>;
}

View File

@@ -0,0 +1,37 @@
/**
* Repository URL validation (JS mirror for server.js).
*/
const VALID_REPO_URL =
/^(https?:\/\/)(github\.com|gitlab\.com|bitbucket\.org|[^/]+)\/[^/]+\/[^/]+$/;
export const REPO_URL_ERROR_MESSAGE =
'Invalid repository URL. Supported: GitHub, GitLab, Bitbucket, and custom Git servers (e.g. https://host/owner/repo).';
export function isValidRepositoryUrl(url) {
if (typeof url !== 'string' || !url.trim()) return false;
return VALID_REPO_URL.test(url.trim());
}
export function getRepoProvider(url) {
if (!isValidRepositoryUrl(url)) throw new Error(REPO_URL_ERROR_MESSAGE);
const normalized = url.trim().toLowerCase();
if (normalized.includes('github.com')) return 'github';
if (normalized.includes('gitlab.com')) return 'gitlab';
if (normalized.includes('bitbucket.org')) return 'bitbucket';
return 'custom';
}
export function parseRepoUrl(url) {
if (!isValidRepositoryUrl(url)) throw new Error(REPO_URL_ERROR_MESSAGE);
try {
const u = new URL(url.trim());
const pathParts = u.pathname.replace(/^\/+/, '').replace(/\.git\/?$/, '').split('/');
return {
origin: u.origin,
owner: pathParts[0] ?? '',
repo: pathParts[1] ?? '',
};
} catch {
throw new Error(REPO_URL_ERROR_MESSAGE);
}
}

View File

@@ -0,0 +1,57 @@
/**
* Repository URL validation and provider detection.
* Supports GitHub, GitLab, Bitbucket, and custom Git servers.
*/
const VALID_REPO_URL =
/^(https?:\/\/)(github\.com|gitlab\.com|bitbucket\.org|[^/]+)\/[^/]+\/[^/]+$/;
export const REPO_URL_ERROR_MESSAGE =
'Invalid repository URL. Supported: GitHub, GitLab, Bitbucket, and custom Git servers (e.g. https://host/owner/repo).';
export type RepoProvider = 'github' | 'gitlab' | 'bitbucket' | 'custom';
/**
* Check if a string is a valid repository URL (format only).
*/
export function isValidRepositoryUrl(url: string): boolean {
if (typeof url !== 'string' || !url.trim()) return false;
return VALID_REPO_URL.test(url.trim());
}
/**
* Detect the Git provider from a repository URL.
*/
export function getRepoProvider(url: string): RepoProvider {
if (!isValidRepositoryUrl(url)) {
throw new Error(REPO_URL_ERROR_MESSAGE);
}
const normalized = url.trim().toLowerCase();
if (normalized.includes('github.com')) return 'github';
if (normalized.includes('gitlab.com')) return 'gitlab';
if (normalized.includes('bitbucket.org')) return 'bitbucket';
return 'custom';
}
/**
* Parse owner and repo from a repository URL (path segments).
* Works for GitHub, GitLab, Bitbucket, and custom (host/owner/repo).
*/
export function parseRepoUrl(url: string): { origin: string; owner: string; repo: string } {
if (!isValidRepositoryUrl(url)) {
throw new Error(REPO_URL_ERROR_MESSAGE);
}
try {
const u = new URL(url.trim());
const pathParts = u.pathname.replace(/^\/+/, '').replace(/\.git\/?$/, '').split('/');
const owner = pathParts[0] ?? '';
const repo = pathParts[1] ?? '';
return {
origin: u.origin,
owner,
repo,
};
} catch {
throw new Error(REPO_URL_ERROR_MESSAGE);
}
}

View File

@@ -272,6 +272,12 @@ export class AutoSyncService {
console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`);
/** @type {any} */
const cronOptions = {
scheduled: true,
timezone: 'UTC'
};
this.cronJob = cron.schedule(cronExpression, async () => {
// Check global lock first
if (globalAutoSyncLock) {
@@ -300,10 +306,7 @@ export class AutoSyncService {
console.log('Starting scheduled auto-sync...');
await this.executeAutoSync();
}, {
scheduled: true,
timezone: 'UTC'
});
}, cronOptions);
console.log('Auto-sync cron job scheduled successfully');
}
@@ -373,7 +376,7 @@ export class AutoSyncService {
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`);
// Get scripts only for the synced files
const localScriptsService = await import('./localScripts.js');
const localScriptsService = await import('./localScripts');
const syncedScripts = [];
for (const filename of syncResult.syncedFiles) {

View File

@@ -25,20 +25,20 @@ class BackupService {
let hostname = '';
await new Promise<void>((resolve, reject) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
'hostname',
(data: string) => {
hostname += data;
},
(error: string) => {
reject(new Error(`Failed to get hostname: ${error}`));
(_error: string) => {
reject(new Error(`Failed to get hostname: ${_error}`));
},
(exitCode: number) => {
if (exitCode === 0) {
(_exitCode: number) => {
if (_exitCode === 0) {
resolve();
} else {
reject(new Error(`hostname command failed with exit code ${exitCode}`));
reject(new Error(`hostname command failed with exit code ${_exitCode}`));
}
}
);
@@ -61,17 +61,19 @@ class BackupService {
try {
await Promise.race([
new Promise<void>((resolve) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
findCommand,
(data: string) => {
findOutput += data;
},
(error: string) => {
console.error('Error getting hostname:', error);
// Ignore errors - directory might not exist
resolve();
},
(exitCode: number) => {
console.error('Error getting find command:', exitCode);
resolve();
}
);
@@ -96,7 +98,7 @@ class BackupService {
await Promise.race([
new Promise<void>((resolve) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
statCommand,
(data: string) => {
@@ -112,11 +114,11 @@ class BackupService {
]);
const statParts = statOutput.trim().split('|');
const fileName = backupPath.split('/').pop() || backupPath;
const fileName = backupPath.split('/').pop() ?? backupPath;
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
const size = BigInt(statParts[0] || '0');
const mtime = parseInt(statParts[1] || '0', 10);
const size = BigInt(statParts[0] ?? '0');
const mtime = parseInt(statParts[1] ?? '0', 10);
backups.push({
container_id: ctId,
@@ -144,8 +146,9 @@ class BackupService {
});
}
} catch (error) {
console.error('Error processing backup:', error);
// Still try to add the backup even if stat fails
const fileName = backupPath.split('/').pop() || backupPath;
const fileName = backupPath.split('/').pop() ?? backupPath;
backups.push({
container_id: ctId,
server_id: server.id,
@@ -182,17 +185,18 @@ class BackupService {
try {
await Promise.race([
new Promise<void>((resolve) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
findCommand,
(data: string) => {
findOutput += data;
},
(error: string) => {
// Ignore errors - storage might not be mounted
console.error('Error getting stat command:', error);
resolve();
},
(exitCode: number) => {
console.error('Error getting stat command:', exitCode);
resolve();
}
);
@@ -218,7 +222,7 @@ class BackupService {
await Promise.race([
new Promise<void>((resolve) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
statCommand,
(data: string) => {
@@ -234,11 +238,11 @@ class BackupService {
]);
const statParts = statOutput.trim().split('|');
const fileName = backupPath.split('/').pop() || backupPath;
const fileName = backupPath.split('/').pop() ?? backupPath;
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
const size = BigInt(statParts[0] || '0');
const mtime = parseInt(statParts[1] || '0', 10);
const size = BigInt(statParts[0] ?? '0');
const mtime = parseInt(statParts[1] ?? '0', 10);
backups.push({
container_id: ctId,
@@ -270,7 +274,7 @@ class BackupService {
} catch (error) {
console.error(`Error processing backup ${backupPath}:`, error);
// Still try to add the backup even if stat fails
const fileName = backupPath.split('/').pop() || backupPath;
const fileName = backupPath.split('/').pop() ?? backupPath;
backups.push({
container_id: ctId,
server_id: server.id,
@@ -310,8 +314,8 @@ class BackupService {
const pbsInfo = storageService.getPBSStorageInfo(storage);
// Use IP and datastore from credentials (they override config if different)
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
if (!pbsIp || !pbsDatastore) {
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
@@ -323,13 +327,16 @@ class BackupService {
// PBS supports PBS_PASSWORD and PBS_REPOSITORY environment variables for non-interactive login
const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
// Escape password for shell safety (single quotes)
// Escape password and fingerprint for shell safety (single quotes)
const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''");
// Use PBS_PASSWORD environment variable for non-interactive authentication
// Auto-accept fingerprint by piping "y" to stdin
// PBS will use PBS_PASSWORD env var if available, avoiding interactive prompt
const fullCommand = `echo "y" | PBS_PASSWORD='${escapedPassword}' PBS_REPOSITORY='${repository}' timeout 10 proxmox-backup-client login --repository ${repository} 2>&1`;
const fingerprint = credential.pbs_fingerprint?.trim() ?? '';
const escapedFingerprint = fingerprint ? fingerprint.replace(/'/g, "'\\''") : '';
const envParts = [`PBS_PASSWORD='${escapedPassword}'`, `PBS_REPOSITORY='${repository}'`];
if (escapedFingerprint) {
envParts.push(`PBS_FINGERPRINT='${escapedFingerprint}'`);
}
const envStr = envParts.join(' ');
const fullCommand = `${envStr} timeout 10 proxmox-backup-client login --repository ${repository} 2>&1`;
console.log(`[BackupService] Logging into PBS: ${repository}`);
@@ -339,7 +346,7 @@ class BackupService {
try {
await Promise.race([
new Promise<void>((resolve) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
fullCommand,
(data: string) => {
@@ -405,8 +412,8 @@ class BackupService {
const storageService = getStorageService();
const pbsInfo = storageService.getPBSStorageInfo(storage);
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
if (!pbsIp || !pbsDatastore) {
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
@@ -415,9 +422,12 @@ class BackupService {
// Build full repository string: root@pam@<IP>:<DATASTORE>
const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
const fingerprint = credential.pbs_fingerprint?.trim() ?? '';
const escapedFingerprint = fingerprint ? fingerprint.replace(/'/g, "'\\''") : '';
const snapshotEnvParts = escapedFingerprint ? [`PBS_FINGERPRINT='${escapedFingerprint}'`] : [];
const snapshotEnvStr = snapshotEnvParts.length ? snapshotEnvParts.join(' ') + ' ' : '';
// Use correct command: snapshot list ct/<CT_ID> --repository <full_repo_string>
const command = `timeout 30 proxmox-backup-client snapshot list ct/${ctId} --repository ${repository} 2>&1 || echo "PBS_ERROR"`;
const command = `${snapshotEnvStr}timeout 30 proxmox-backup-client snapshot list ct/${ctId} --repository ${repository} 2>&1 || echo "PBS_ERROR"`;
let output = '';
console.log(`[BackupService] Discovering PBS backups for CT ${ctId} on repository ${repository}`);
@@ -425,8 +435,8 @@ class BackupService {
try {
// Add timeout to prevent hanging
await Promise.race([
new Promise<void>((resolve, reject) => {
sshService.executeCommand(
new Promise<void>((resolve) => {
void sshService.executeCommand(
server,
command,
(data: string) => {
@@ -468,7 +478,7 @@ class BackupService {
if (line.includes('snapshot') && line.includes('size') && line.includes('files')) {
continue; // Skip header row
}
if (line.includes('═') || line.includes('─') || line.includes('│') && line.match(/^[│═─╞╪╡├┼┤└┴┘]+$/)) {
if (line.includes('═') || line.includes('─') || line.includes('│') && (/^[│═─╞╪╡├┼┤└┴┘]+$/.exec(line))) {
continue; // Skip table separator lines
}
if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) {
@@ -489,7 +499,7 @@ class BackupService {
// Extract snapshot name (last part after /)
const snapshotParts = snapshotPath.split('/');
const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath;
const snapshotName = snapshotParts[snapshotParts.length - 1] ?? snapshotPath;
if (!snapshotName) {
continue; // Skip if no snapshot name
@@ -497,11 +507,12 @@ class BackupService {
// Parse date from snapshot name (format: 2025-10-21T19:14:55Z)
let createdAt: Date | undefined;
const dateMatch = snapshotName.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/);
if (dateMatch && dateMatch[1]) {
const dateMatch = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/.exec(snapshotName);
if (dateMatch?.[1]) {
try {
createdAt = new Date(dateMatch[1]);
} catch (e) {
console.error('Error parsing date:', e);
// Invalid date, leave undefined
}
}
@@ -509,8 +520,8 @@ class BackupService {
// Parse size (convert MiB/GiB to bytes)
let size: bigint | undefined;
if (sizeStr) {
const sizeMatch = sizeStr.match(/([\d.]+)\s*(MiB|GiB|KiB|B)/i);
if (sizeMatch && sizeMatch[1] && sizeMatch[2]) {
const sizeMatch = /([\d.]+)\s*(MiB|GiB|KiB|B)/i.exec(sizeStr);
if (sizeMatch?.[1] && sizeMatch[2]) {
const sizeValue = parseFloat(sizeMatch[1]);
const unit = sizeMatch[2].toUpperCase();
let bytes = sizeValue;
@@ -640,18 +651,18 @@ class BackupService {
if (!script.container_id || !script.server_id || !script.server) continue;
const containerId = script.container_id;
const serverId = script.server_id;
const server = script.server as Server;
try {
// Get hostname from LXC config if available, otherwise use script name
let hostname = script.script_name || `CT-${script.container_id}`;
let hostname = script.script_name ?? `CT-${script.container_id}`;
try {
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
if (lxcConfig?.hostname) {
hostname = lxcConfig.hostname;
}
} catch (error) {
console.error('Error getting LXC config:', error);
// LXC config might not exist, use script name
console.debug(`No LXC config found for script ${script.id}, using script name as hostname`);
}
@@ -682,9 +693,7 @@ class BackupService {
let backupServiceInstance: BackupService | null = null;
export function getBackupService(): BackupService {
if (!backupServiceInstance) {
backupServiceInstance = new BackupService();
}
backupServiceInstance ??= new BackupService();
return backupServiceInstance;
}

View File

@@ -1,6 +1,359 @@
// JavaScript wrapper for githubJsonService.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
// JavaScript wrapper for githubJsonService (for use with node server.js)
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { repositoryService } from './repositoryService.js';
import { listDirectory, downloadRawFile } from '../lib/gitProvider/index.js';
import { githubJsonService } from './githubJsonService.ts';
// Get environment variables
const getEnv = () => ({
REPO_BRANCH: process.env.REPO_BRANCH || 'main',
JSON_FOLDER: process.env.JSON_FOLDER || 'json',
REPO_URL: process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE',
GITHUB_TOKEN: process.env.GITHUB_TOKEN
});
export { githubJsonService };
class GitHubJsonService {
constructor() {
this.branch = null;
this.jsonFolder = null;
this.localJsonDirectory = null;
this.scriptCache = new Map();
}
initializeConfig() {
if (this.branch === null) {
const env = getEnv();
this.branch = env.REPO_BRANCH;
this.jsonFolder = env.JSON_FOLDER;
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json');
}
}
async downloadJsonFile(repoUrl, filePath) {
this.initializeConfig();
const content = await downloadRawFile(repoUrl, filePath, this.branch);
const script = JSON.parse(content);
script.repository_url = repoUrl;
return script;
}
async getJsonFiles(repoUrl) {
this.initializeConfig();
try {
const entries = await listDirectory(repoUrl, this.jsonFolder, this.branch);
return entries
.filter((e) => e.type === 'file' && e.name.endsWith('.json'))
.map((e) => ({ name: e.name, path: e.path }));
} catch (error) {
console.error(`Error fetching JSON files from repository (${repoUrl}):`, error);
throw new Error(`Failed to fetch script files from repository: ${repoUrl}`);
}
}
async getAllScripts(repoUrl) {
try {
const jsonFiles = await this.getJsonFiles(repoUrl);
const scripts = [];
for (const file of jsonFiles) {
try {
const script = await this.downloadJsonFile(repoUrl, file.path);
scripts.push(script);
} catch (error) {
console.error(`Failed to download script ${file.name} from ${repoUrl}:`, error);
}
}
return scripts;
} catch (error) {
console.error(`Error fetching all scripts from ${repoUrl}:`, error);
throw new Error(`Failed to fetch scripts from repository: ${repoUrl}`);
}
}
async getScriptCards(repoUrl) {
try {
const scripts = await this.getAllScripts(repoUrl);
return scripts.map(script => ({
name: script.name,
slug: script.slug,
description: script.description,
logo: script.logo,
type: script.type,
updateable: script.updateable,
website: script.website,
repository_url: script.repository_url,
}));
} catch (error) {
console.error(`Error creating script cards from ${repoUrl}:`, error);
throw new Error(`Failed to create script cards from repository: ${repoUrl}`);
}
}
async getScriptBySlug(slug, repoUrl) {
try {
const localScript = await this.getScriptFromLocal(slug);
if (localScript) {
if (repoUrl && localScript.repository_url !== repoUrl) {
return null;
}
return localScript;
}
if (repoUrl) {
try {
this.initializeConfig();
const script = await this.downloadJsonFile(repoUrl, `${this.jsonFolder}/${slug}.json`);
return script;
} catch {
return null;
}
}
const enabledRepos = await repositoryService.getEnabledRepositories();
for (const repo of enabledRepos) {
try {
this.initializeConfig();
const script = await this.downloadJsonFile(repo.url, `${this.jsonFolder}/${slug}.json`);
return script;
} catch {
// Continue to next repo
}
}
return null;
} catch (error) {
console.error('Error fetching script by slug:', error);
throw new Error(`Failed to fetch script: ${slug}`);
}
}
async getScriptFromLocal(slug) {
try {
if (this.scriptCache.has(slug)) {
return this.scriptCache.get(slug);
}
this.initializeConfig();
const filePath = join(this.localJsonDirectory, `${slug}.json`);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content);
if (!script.repository_url) {
const env = getEnv();
script.repository_url = env.REPO_URL;
}
this.scriptCache.set(slug, script);
return script;
} catch {
return null;
}
}
async syncJsonFilesForRepo(repoUrl) {
try {
console.log(`Starting JSON sync from repository: ${repoUrl}`);
const githubFiles = await this.getJsonFiles(repoUrl);
console.log(`Found ${githubFiles.length} JSON files in repository ${repoUrl}`);
const localFiles = await this.getLocalJsonFiles();
console.log(`Found ${localFiles.length} local JSON files`);
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
if (filesToSync.length === 0) {
return {
success: true,
message: `All JSON files are up to date for repository: ${repoUrl}`,
count: 0,
syncedFiles: []
};
}
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
return {
success: true,
message: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`,
count: syncedFiles.length,
syncedFiles
};
} catch (error) {
console.error(`JSON sync failed for ${repoUrl}:`, error);
return {
success: false,
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0,
syncedFiles: []
};
}
}
async syncJsonFiles() {
try {
console.log('Starting multi-repository JSON sync...');
const enabledRepos = await repositoryService.getEnabledRepositories();
if (enabledRepos.length === 0) {
return {
success: false,
message: 'No enabled repositories found',
count: 0,
syncedFiles: []
};
}
console.log(`Found ${enabledRepos.length} enabled repositories`);
const allSyncedFiles = [];
const processedSlugs = new Set();
let totalSynced = 0;
for (const repo of enabledRepos) {
try {
console.log(`Syncing from repository: ${repo.url} (priority: ${repo.priority})`);
const result = await this.syncJsonFilesForRepo(repo.url);
if (result.success) {
const newFiles = result.syncedFiles.filter(file => {
const slug = file.replace('.json', '');
if (processedSlugs.has(slug)) {
return false;
}
processedSlugs.add(slug);
return true;
});
allSyncedFiles.push(...newFiles);
totalSynced += newFiles.length;
} else {
console.error(`Failed to sync from ${repo.url}: ${result.message}`);
}
} catch (error) {
console.error(`Error syncing from ${repo.url}:`, error);
}
}
await this.updateExistingFilesWithRepositoryUrl();
return {
success: true,
message: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`,
count: totalSynced,
syncedFiles: allSyncedFiles
};
} catch (error) {
console.error('Multi-repository JSON sync failed:', error);
return {
success: false,
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0,
syncedFiles: []
};
}
}
async updateExistingFilesWithRepositoryUrl() {
try {
this.initializeConfig();
const files = await this.getLocalJsonFiles();
const env = getEnv();
const mainRepoUrl = env.REPO_URL;
for (const file of files) {
try {
const filePath = join(this.localJsonDirectory, file);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content);
if (!script.repository_url) {
script.repository_url = mainRepoUrl;
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
console.log(`Updated ${file} with repository_url: ${mainRepoUrl}`);
}
} catch (error) {
console.error(`Error updating ${file}:`, error);
}
}
} catch (error) {
console.error('Error updating existing files with repository_url:', error);
}
}
async getLocalJsonFiles() {
this.initializeConfig();
try {
const files = await readdir(this.localJsonDirectory);
return files.filter(f => f.endsWith('.json'));
} catch {
return [];
}
}
async findFilesToSyncForRepo(repoUrl, githubFiles, localFiles) {
const filesToSync = [];
for (const ghFile of githubFiles) {
const localFilePath = join(this.localJsonDirectory, ghFile.name);
let needsSync = false;
if (!localFiles.includes(ghFile.name)) {
needsSync = true;
} else {
try {
const content = await readFile(localFilePath, 'utf-8');
const script = JSON.parse(content);
if (!script.repository_url || script.repository_url !== repoUrl) {
needsSync = true;
}
} catch {
needsSync = true;
}
}
if (needsSync) {
filesToSync.push(ghFile);
}
}
return filesToSync;
}
async syncSpecificFiles(repoUrl, filesToSync) {
this.initializeConfig();
const syncedFiles = [];
await mkdir(this.localJsonDirectory, { recursive: true });
for (const file of filesToSync) {
try {
const script = await this.downloadJsonFile(repoUrl, file.path);
const filename = `${script.slug}.json`;
const filePath = join(this.localJsonDirectory, filename);
script.repository_url = repoUrl;
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
syncedFiles.push(filename);
this.scriptCache.delete(script.slug);
} catch (error) {
console.error(`Failed to sync ${file.name} from ${repoUrl}:`, error);
}
}
return syncedFiles;
}
}
// Singleton instance
export const githubJsonService = new GitHubJsonService();

View File

@@ -2,7 +2,8 @@ import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { env } from '../../env.js';
import type { Script, ScriptCard, GitHubFile } from '../../types/script';
import { repositoryService } from './repositoryService.ts';
import { repositoryService } from './repositoryService';
import { listDirectory, downloadRawFile } from '~/server/lib/gitProvider';
export class GitHubJsonService {
private branch: string | null = null;
@@ -22,95 +23,24 @@ export class GitHubJsonService {
}
}
private getBaseUrl(repoUrl: string): string {
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!urlMatch) {
throw new Error(`Invalid GitHub repository URL: ${repoUrl}`);
}
const [, owner, repo] = urlMatch;
return `https://api.github.com/repos/${owner}/${repo}`;
}
private extractRepoPath(repoUrl: string): string {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!match) {
throw new Error('Invalid GitHub repository URL');
}
return `${match[1]}/${match[2]}`;
}
private async fetchFromGitHub<T>(repoUrl: string, endpoint: string): Promise<T> {
const baseUrl = this.getBaseUrl(repoUrl);
const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(`${baseUrl}${endpoint}`, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
this.initializeConfig();
const repoPath = this.extractRepoPath(repoUrl);
const rawUrl = `https://raw.githubusercontent.com/${repoPath}/${this.branch!}/${filePath}`;
const headers: HeadersInit = {
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(rawUrl, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
const content = await response.text();
const content = await downloadRawFile(repoUrl, filePath, this.branch!);
const script = JSON.parse(content) as Script;
// Add repository_url to script
script.repository_url = repoUrl;
return script;
}
async getJsonFiles(repoUrl: string): Promise<GitHubFile[]> {
this.initializeConfig();
try {
const files = await this.fetchFromGitHub<GitHubFile[]>(
repoUrl,
`/contents/${this.jsonFolder!}?ref=${this.branch!}`
);
// Filter for JSON files only
return files.filter(file => file.name.endsWith('.json'));
const entries = await listDirectory(repoUrl, this.jsonFolder!, this.branch!);
const files: GitHubFile[] = entries
.filter((e) => e.type === 'file' && e.name.endsWith('.json'))
.map((e) => ({ name: e.name, path: e.path } as GitHubFile));
return files;
} catch (error) {
console.error(`Error fetching JSON files from GitHub (${repoUrl}):`, error);
console.error(`Error fetching JSON files from repository (${repoUrl}):`, error);
throw new Error(`Failed to fetch script files from repository: ${repoUrl}`);
}
}
@@ -214,9 +144,7 @@ export class GitHubJsonService {
const script = JSON.parse(content) as Script;
// If script doesn't have repository_url, set it to main repo (for backward compatibility)
if (!script.repository_url) {
script.repository_url = env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
}
script.repository_url ??= env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
// Cache the script
this.scriptCache.set(slug, script);
@@ -234,8 +162,7 @@ export class GitHubJsonService {
try {
console.log(`Starting JSON sync from repository: ${repoUrl}`);
// Get file list from GitHub
console.log(`Fetching file list from GitHub (${repoUrl})...`);
console.log(`Fetching file list from repository (${repoUrl})...`);
const githubFiles = await this.getJsonFiles(repoUrl);
console.log(`Found ${githubFiles.length} JSON files in repository ${repoUrl}`);
@@ -397,7 +324,6 @@ export class GitHubJsonService {
const filesToSync: GitHubFile[] = [];
for (const ghFile of githubFiles) {
const slug = ghFile.name.replace('.json', '');
const localFilePath = join(this.localJsonDirectory!, ghFile.name);
let needsSync = false;

View File

@@ -1,6 +0,0 @@
// JavaScript wrapper for localScripts.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
import { localScriptsService } from './localScripts.ts';
export { localScriptsService };

View File

@@ -1,3 +1,4 @@
import { readFile, readdir, writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import type { Script, ScriptCard } from '~/types/script';
@@ -95,7 +96,7 @@ export class LocalScriptsService {
let foundRepo: string | null = null;
for (const repo of enabledRepos) {
try {
const { githubJsonService } = await import('./githubJsonService.js');
const { githubJsonService } = await import('./githubJsonService');
const repoScript = await githubJsonService.getScriptBySlug(slug, repo.url);
if (repoScript) {
foundRepo = repo.url;

View File

@@ -0,0 +1,215 @@
// JavaScript wrapper for repositoryService (for use with node server.js)
import { prisma } from '../db.js';
import { isValidRepositoryUrl, REPO_URL_ERROR_MESSAGE } from '../lib/repositoryUrlValidation.js';
class RepositoryService {
/**
* Initialize default repositories if they don't exist
*/
async initializeDefaultRepositories() {
const mainRepoUrl = 'https://github.com/community-scripts/ProxmoxVE';
const devRepoUrl = 'https://github.com/community-scripts/ProxmoxVED';
// Check if repositories already exist
const existingRepos = await prisma.repository.findMany({
where: {
url: {
in: [mainRepoUrl, devRepoUrl]
}
}
});
const existingUrls = new Set(existingRepos.map((r) => r.url));
// Create main repo if it doesn't exist
if (!existingUrls.has(mainRepoUrl)) {
await prisma.repository.create({
data: {
url: mainRepoUrl,
enabled: true,
is_default: true,
is_removable: false,
priority: 1
}
});
console.log('Initialized main repository:', mainRepoUrl);
}
// Create dev repo if it doesn't exist
if (!existingUrls.has(devRepoUrl)) {
await prisma.repository.create({
data: {
url: devRepoUrl,
enabled: false,
is_default: true,
is_removable: false,
priority: 2
}
});
console.log('Initialized dev repository:', devRepoUrl);
}
}
/**
* Get all repositories, sorted by priority
*/
async getAllRepositories() {
return await prisma.repository.findMany({
orderBy: [
{ priority: 'asc' },
{ created_at: 'asc' }
]
});
}
/**
* Get enabled repositories, sorted by priority
*/
async getEnabledRepositories() {
return await prisma.repository.findMany({
where: {
enabled: true
},
orderBy: [
{ priority: 'asc' },
{ created_at: 'asc' }
]
});
}
/**
* Get repository by URL
*/
async getRepositoryByUrl(url) {
return await prisma.repository.findUnique({
where: { url }
});
}
/**
* Create a new repository
*/
async createRepository(data) {
if (!isValidRepositoryUrl(data.url)) {
throw new Error(REPO_URL_ERROR_MESSAGE);
}
// Check for duplicates
const existing = await this.getRepositoryByUrl(data.url);
if (existing) {
throw new Error('Repository already exists');
}
// Get max priority for user-added repos
const maxPriority = await prisma.repository.aggregate({
_max: {
priority: true
}
});
return await prisma.repository.create({
data: {
url: data.url,
enabled: data.enabled ?? true,
is_default: false,
is_removable: true,
priority: data.priority ?? (maxPriority._max.priority ?? 0) + 1
}
});
}
/**
* Update repository
*/
async updateRepository(id, data) {
if (data.url) {
if (!isValidRepositoryUrl(data.url)) {
throw new Error(REPO_URL_ERROR_MESSAGE);
}
// Check for duplicates (excluding current repo)
const existing = await prisma.repository.findFirst({
where: {
url: data.url,
id: { not: id }
}
});
if (existing) {
throw new Error('Repository URL already exists');
}
}
return await prisma.repository.update({
where: { id },
data
});
}
/**
* Delete repository and associated JSON files
*/
async deleteRepository(id) {
const repo = await prisma.repository.findUnique({
where: { id }
});
if (!repo) {
throw new Error('Repository not found');
}
if (!repo.is_removable) {
throw new Error('Cannot delete default repository');
}
// Delete associated JSON files
await this.deleteRepositoryJsonFiles(repo.url);
// Delete repository
await prisma.repository.delete({
where: { id }
});
return { success: true };
}
/**
* Delete all JSON files associated with a repository
*/
async deleteRepositoryJsonFiles(repoUrl) {
const { readdir, unlink, readFile } = await import('fs/promises');
const { join } = await import('path');
const jsonDirectory = join(process.cwd(), 'scripts', 'json');
try {
const files = await readdir(jsonDirectory);
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const filePath = join(jsonDirectory, file);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content);
// If script has repository_url matching the repo, delete it
if (script.repository_url === repoUrl) {
await unlink(filePath);
console.log(`Deleted JSON file: ${file} (from repository: ${repoUrl})`);
}
} catch (error) {
// Skip files that can't be read or parsed
console.error(`Error processing file ${file}:`, error);
}
}
} catch (error) {
// Directory might not exist, which is fine
if (error.code !== 'ENOENT') {
console.error('Error deleting repository JSON files:', error);
}
}
}
}
// Singleton instance
export const repositoryService = new RepositoryService();

View File

@@ -1,4 +1,5 @@
import { prisma } from '../db.ts';
import { prisma } from '../db';
import { isValidRepositoryUrl, REPO_URL_ERROR_MESSAGE } from '../lib/repositoryUrlValidation';
export class RepositoryService {
/**
@@ -17,7 +18,7 @@ export class RepositoryService {
}
});
const existingUrls = new Set(existingRepos.map(r => r.url));
const existingUrls = new Set(existingRepos.map((r: { url: string }) => r.url));
// Create main repo if it doesn't exist
if (!existingUrls.has(mainRepoUrl)) {
@@ -92,9 +93,8 @@ export class RepositoryService {
enabled?: boolean;
priority?: number;
}) {
// Validate GitHub URL
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
if (!isValidRepositoryUrl(data.url)) {
throw new Error(REPO_URL_ERROR_MESSAGE);
}
// Check for duplicates
@@ -129,10 +129,9 @@ export class RepositoryService {
url?: string;
priority?: number;
}) {
// If updating URL, validate it
if (data.url) {
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
if (!isValidRepositoryUrl(data.url)) {
throw new Error(REPO_URL_ERROR_MESSAGE);
}
// Check for duplicates (excluding current repo)

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-optional-chain, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/no-empty-function */
import { getSSHExecutionService } from '../ssh-execution-service';
import { getBackupService } from './backupService';
import { getStorageService } from './storageService';
import { getDatabase } from '../database-prisma';
import type { Server } from '~/types/server';
import type { Storage } from './storageService';
import { writeFile, readFile } from 'fs/promises';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
export interface RestoreProgress {
step: string;
@@ -76,7 +76,7 @@ class RestoreService {
}
return null;
} catch (error) {
} catch {
// Try fallback to database
try {
const installedScripts = await db.getAllInstalledScripts();
@@ -90,7 +90,7 @@ class RestoreService {
}
}
}
} catch (dbError) {
} catch {
// Ignore database error
}
return null;
@@ -231,7 +231,6 @@ class RestoreService {
const snapshotNameForPath = snapshotName.replace(/:/g, '_');
// Determine file extension - try common extensions
const extensions = ['.tar', '.tar.zst', '.pxar'];
let downloadedPath = '';
let downloadSuccess = false;
@@ -251,9 +250,16 @@ class RestoreService {
const targetFolder = `/var/lib/vz/dump/vzdump-lxc-${ctId}-${snapshotNameForPath}`;
const targetTar = `${targetFolder}.tar`;
// Use PBS_PASSWORD env var and add timeout for long downloads
// Use PBS_PASSWORD env var and add timeout for long downloads; PBS_FINGERPRINT when set for cert validation
const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''");
const restoreCommand = `PBS_PASSWORD='${escapedPassword}' PBS_REPOSITORY='${repository}' timeout 300 proxmox-backup-client restore "${snapshotPath}" root.pxar "${targetFolder}" --repository '${repository}' 2>&1`;
const fingerprint = credential.pbs_fingerprint?.trim() ?? '';
const escapedFingerprint = fingerprint ? fingerprint.replace(/'/g, "'\\''") : '';
const restoreEnvParts = [`PBS_PASSWORD='${escapedPassword}'`, `PBS_REPOSITORY='${repository}'`];
if (escapedFingerprint) {
restoreEnvParts.push(`PBS_FINGERPRINT='${escapedFingerprint}'`);
}
const restoreEnvStr = restoreEnvParts.join(' ');
const restoreCommand = `${restoreEnvStr} timeout 300 proxmox-backup-client restore "${snapshotPath}" root.pxar "${targetFolder}" --repository '${repository}' 2>&1`;
let output = '';
let exitCode = 0;
@@ -408,7 +414,7 @@ class RestoreService {
const clearLogFile = async () => {
try {
await writeFile(logPath, '', 'utf-8');
} catch (error) {
} catch {
// Ignore log file errors
}
};
@@ -418,7 +424,7 @@ class RestoreService {
try {
const logLine = `${message}\n`;
await writeFile(logPath, logLine, { flag: 'a', encoding: 'utf-8' });
} catch (error) {
} catch {
// Ignore log file errors
}
};
@@ -452,10 +458,12 @@ class RestoreService {
}
// Get server details
const server = await db.getServerById(serverId);
if (!server) {
const serverData = await db.getServerById(serverId);
if (!serverData) {
throw new Error(`Server with ID ${serverId} not found`);
}
// Cast to Server type (Prisma returns nullable fields as null, Server uses undefined)
const server = serverData as unknown as Server;
// Get rootfs storage
await addProgress('reading_config', 'Reading container configuration...');
@@ -489,7 +497,7 @@ class RestoreService {
await addProgress('stopping', 'Stopping container...');
try {
await this.stopContainer(server, containerId);
} catch (error) {
} catch {
// Continue even if stop fails
}
@@ -497,7 +505,7 @@ class RestoreService {
await addProgress('destroying', 'Destroying container...');
try {
await this.destroyContainer(server, containerId);
} catch (error) {
} catch {
// Container might not exist, which is fine - continue with restore
await addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
}
@@ -559,3 +567,4 @@ export function getRestoreService(): RestoreService {
return restoreServiceInstance;
}

View File

@@ -1,24 +1,27 @@
// Real JavaScript implementation for script downloading
import { join } from 'path';
import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
import { downloadRawFile } from '../lib/gitProvider/index.js';
export class ScriptDownloaderService {
constructor() {
this.scriptsDirectory = null;
this.repoUrl = null;
/** @type {string} */
this.scriptsDirectory = join(process.cwd(), 'scripts');
/** @type {string} */
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
}
initializeConfig() {
if (this.scriptsDirectory === null) {
this.scriptsDirectory = join(process.cwd(), 'scripts');
// Get REPO_URL from environment or use default
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
}
// Re-initialize if needed (for environment changes)
this.scriptsDirectory = join(process.cwd(), 'scripts');
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
}
/**
* Validates that a directory path doesn't contain nested directories with the same name
* (e.g., prevents ct/ct or install/install)
* @param {string} dirPath - The directory path to validate
* @returns {boolean}
*/
validateDirectoryPath(dirPath) {
const normalizedPath = dirPath.replace(/\\/g, '/');
@@ -36,6 +39,9 @@ export class ScriptDownloaderService {
/**
* Validates that finalTargetDir doesn't contain nested directory names like ct/ct or install/install
* @param {string} targetDir - The base target directory
* @param {string} finalTargetDir - The final target directory to validate
* @returns {string}
*/
validateTargetDir(targetDir, finalTargetDir) {
// Check if finalTargetDir contains nested directory names
@@ -53,6 +59,11 @@ export class ScriptDownloaderService {
return finalTargetDir;
}
/**
* Ensure a directory exists, creating it if necessary
* @param {string} dirPath - The directory path to ensure exists
* @returns {Promise<void>}
*/
async ensureDirectoryExists(dirPath) {
// Validate the directory path to prevent nested directories with the same name
this.validateDirectoryPath(dirPath);
@@ -61,7 +72,7 @@ export class ScriptDownloaderService {
console.log(`[Directory Creation] Ensuring directory exists: ${dirPath}`);
await mkdir(dirPath, { recursive: true });
console.log(`[Directory Creation] Directory created/verified: ${dirPath}`);
} catch (error) {
} catch (/** @type {any} */ error) {
if (error.code !== 'EEXIST') {
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, error.message);
throw error;
@@ -71,41 +82,26 @@ export class ScriptDownloaderService {
}
}
extractRepoPath(repoUrl) {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!match) {
throw new Error(`Invalid GitHub repository URL: ${repoUrl}`);
}
return `${match[1]}/${match[2]}`;
}
async downloadFileFromGitHub(repoUrl, filePath, branch = 'main') {
this.initializeConfig();
/**
* Download a file from the repository (GitHub, GitLab, Bitbucket, or custom)
* @param {string} repoUrl - The repository URL
* @param {string} filePath - The file path within the repository
* @param {string} [branch] - The branch to download from
* @returns {Promise<string>}
*/
async downloadFileFromRepo(repoUrl, filePath, branch = 'main') {
if (!repoUrl) {
throw new Error('Repository URL is not set');
}
const repoPath = this.extractRepoPath(repoUrl);
const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`;
const headers = {
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `token ${process.env.GITHUB_TOKEN}`;
}
console.log(`Downloading from GitHub: ${url}`);
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`Failed to download ${filePath} from ${repoUrl}: ${response.status} ${response.statusText}`);
}
return response.text();
console.log(`Downloading from repository: ${repoUrl} (${filePath})`);
return downloadRawFile(repoUrl, filePath, branch);
}
/**
* Get repository URL for a script
* @param {import('~/types/script').Script} script - The script object
* @returns {string}
*/
getRepoUrlForScript(script) {
// Use repository_url from script if available, otherwise fallback to env or default
if (script.repository_url) {
@@ -115,6 +111,11 @@ export class ScriptDownloaderService {
return this.repoUrl;
}
/**
* Modify script content to use local paths
* @param {string} content - The script content
* @returns {string}
*/
modifyScriptContent(content) {
// Replace the build.func source line
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
@@ -123,9 +124,15 @@ export class ScriptDownloaderService {
return content.replace(oldPattern, newPattern);
}
/**
* Load a script by downloading its files
* @param {import('~/types/script').Script} script - The script to load
* @returns {Promise<{success: boolean, message: string, files: string[], error?: string}>}
*/
async loadScript(script) {
this.initializeConfig();
try {
/** @type {string[]} */
const files = [];
const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main';
@@ -145,9 +152,8 @@ export class ScriptDownloaderService {
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Download from GitHub using the script's repository URL
console.log(`Downloading script file: ${scriptPath} from ${repoUrl}`);
const content = await this.downloadFileFromGitHub(repoUrl, scriptPath, branch);
const content = await this.downloadFileFromRepo(repoUrl, scriptPath, branch);
// Determine target directory based on script path
let targetDir;
@@ -211,7 +217,7 @@ export class ScriptDownloaderService {
const installScriptName = `${script.slug}-install.sh`;
try {
console.log(`Downloading install script: install/${installScriptName} from ${repoUrl}`);
const installContent = await this.downloadFileFromGitHub(repoUrl, `install/${installScriptName}`, branch);
const installContent = await this.downloadFileFromRepo(repoUrl, `install/${installScriptName}`, branch);
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`);
@@ -235,7 +241,7 @@ export class ScriptDownloaderService {
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
try {
console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName} from ${repoUrl}`);
const alpineInstallContent = await this.downloadFileFromGitHub(repoUrl, `install/${alpineInstallScriptName}`, branch);
const alpineInstallContent = await this.downloadFileFromRepo(repoUrl, `install/${alpineInstallScriptName}`, branch);
const localAlpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
await writeFile(localAlpineInstallPath, alpineInstallContent, 'utf-8');
files.push(`install/${alpineInstallScriptName}`);
@@ -266,6 +272,11 @@ export class ScriptDownloaderService {
}
}
/**
* Check if a script is downloaded
* @param {import('~/types/script').Script} script - The script to check
* @returns {Promise<boolean>}
*/
async isScriptDownloaded(script) {
if (!script.install_methods?.length) return false;
@@ -318,6 +329,11 @@ export class ScriptDownloaderService {
return true;
}
/**
* Check which script files exist locally
* @param {import('~/types/script').Script} script - The script to check
* @returns {Promise<{ctExists: boolean, installExists: boolean, files: string[]}>}
*/
async checkScriptExists(script) {
this.initializeConfig();
const files = [];
@@ -416,6 +432,11 @@ export class ScriptDownloaderService {
}
}
/**
* Delete a script's local files
* @param {import('~/types/script').Script} script - The script to delete
* @returns {Promise<{success: boolean, message: string, deletedFiles: string[]}>}
*/
async deleteScript(script) {
this.initializeConfig();
const deletedFiles = [];
@@ -467,8 +488,14 @@ export class ScriptDownloaderService {
}
}
/**
* Compare local script content with remote
* @param {import('~/types/script').Script} script - The script to compare
* @returns {Promise<{hasDifferences: boolean, differences: string[], error?: string}>}
*/
async compareScriptContent(script) {
this.initializeConfig();
/** @type {string[]} */
const differences = [];
let hasDifferences = false;
const repoUrl = this.getRepoUrlForScript(script);
@@ -519,13 +546,16 @@ export class ScriptDownloaderService {
comparisonPromises.push(
this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`)
.then(result => {
if (result.error) {
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
}
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
.catch((error) => {
console.error(`[Comparison] Promise error for ${scriptPath}:`, error);
})
);
}
@@ -541,13 +571,16 @@ export class ScriptDownloaderService {
comparisonPromises.push(
this.compareSingleFile(script, installScriptPath, installScriptPath)
.then(result => {
if (result.error) {
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
}
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
.catch((error) => {
console.error(`[Comparison] Promise error for ${installScriptPath}:`, error);
})
);
}
@@ -567,13 +600,16 @@ export class ScriptDownloaderService {
comparisonPromises.push(
this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath)
.then(result => {
if (result.error) {
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
}
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
.catch((error) => {
console.error(`[Comparison] Promise error for ${alpineInstallScriptPath}:`, error);
})
);
} catch {
@@ -584,29 +620,42 @@ export class ScriptDownloaderService {
// Wait for all comparisons to complete
await Promise.all(comparisonPromises);
console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`);
return { hasDifferences, differences };
} catch (error) {
console.error('Error comparing script content:', error);
return { hasDifferences: false, differences: [] };
} catch (/** @type {any} */ error) {
console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error);
return { hasDifferences: false, differences: [], error: error.message };
}
}
/**
* Compare a single file with remote
* @param {import('~/types/script').Script} script - The script object
* @param {string} remotePath - The remote file path
* @param {string} filePath - The local file path
* @returns {Promise<{hasDifferences: boolean, filePath: string, error?: string}>}
*/
async compareSingleFile(script, remotePath, filePath) {
try {
const localPath = join(this.scriptsDirectory, filePath);
const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main';
console.log(`[Comparison] Comparing ${filePath} from ${repoUrl} (branch: ${branch})`);
// Read local content
const localContent = await readFile(localPath, 'utf-8');
console.log(`[Comparison] Local file size: ${localContent.length} bytes`);
// Download remote content from the script's repository
const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch);
const remoteContent = await this.downloadFileFromRepo(repoUrl, remotePath, branch);
console.log(`[Comparison] Remote file size: ${remoteContent.length} bytes`);
// Apply modification only for CT scripts, not for other script types
let modifiedRemoteContent;
if (remotePath.startsWith('ct/')) {
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
console.log(`[Comparison] Applied CT script modifications`);
} else {
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
}
@@ -614,13 +663,26 @@ export class ScriptDownloaderService {
// Compare content
const hasDifferences = localContent !== modifiedRemoteContent;
if (hasDifferences) {
console.log(`[Comparison] Differences found in ${filePath}`);
} else {
console.log(`[Comparison] No differences in ${filePath}`);
}
return { hasDifferences, filePath };
} catch (error) {
console.error(`Error comparing file ${filePath}:`, error);
return { hasDifferences: false, filePath };
} catch (/** @type {any} */ error) {
console.error(`[Comparison] Error comparing file ${filePath}:`, error.message);
// Return error information so it can be handled upstream
return { hasDifferences: false, filePath, error: error.message };
}
}
/**
* Get diff between local and remote script
* @param {import('~/types/script').Script} script - The script object
* @param {string} filePath - The file path to diff
* @returns {Promise<{diff: string|null, localContent: string|null, remoteContent: string|null}>}
*/
async getScriptDiff(script, filePath) {
this.initializeConfig();
try {
@@ -644,7 +706,7 @@ export class ScriptDownloaderService {
// Find the corresponding script path in install_methods
const method = script.install_methods?.find(m => m.script === filePath);
if (method?.script) {
const downloadedContent = await this.downloadFileFromGitHub(repoUrl, method.script, branch);
const downloadedContent = await this.downloadFileFromRepo(repoUrl, method.script, branch);
remoteContent = this.modifyScriptContent(downloadedContent);
}
} catch {
@@ -661,7 +723,7 @@ export class ScriptDownloaderService {
}
try {
remoteContent = await this.downloadFileFromGitHub(repoUrl, filePath, branch);
remoteContent = await this.downloadFileFromRepo(repoUrl, filePath, branch);
} catch {
// Error downloading remote install script
}
@@ -680,6 +742,12 @@ export class ScriptDownloaderService {
}
}
/**
* Generate a simple line-by-line diff
* @param {string} localContent - The local file content
* @param {string} remoteContent - The remote file content
* @returns {string}
*/
generateDiff(localContent, remoteContent) {
const localLines = localContent.split('\n');
const remoteLines = remoteContent.split('\n');

View File

@@ -28,8 +28,7 @@ class StorageService {
let currentStorage: Partial<Storage> | null = null;
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i];
for (const rawLine of lines) {
if (!rawLine) continue;
// Check if line is indented (has leading whitespace/tabs) BEFORE trimming
@@ -44,10 +43,10 @@ class StorageService {
// Check if this is a storage definition line (format: "type: name")
// Storage definitions are NOT indented
if (!isIndented) {
const storageMatch = line.match(/^(\w+):\s*(.+)$/);
if (storageMatch && storageMatch[1] && storageMatch[2]) {
const storageMatch = /^(\w+):\s*(.+)$/.exec(line);
if (storageMatch?.[1] && storageMatch[2]) {
// Save previous storage if exists
if (currentStorage && currentStorage.name) {
if (currentStorage?.name) {
storages.push(this.finalizeStorage(currentStorage));
}
@@ -65,9 +64,9 @@ class StorageService {
// Parse storage properties (indented lines - can be tabs or spaces)
if (currentStorage && isIndented) {
// Split on first whitespace (space or tab) to separate key and value
const match = line.match(/^(\S+)\s+(.+)$/);
const match = /^(\S+)\s+(.+)$/.exec(line);
if (match && match[1] && match[2]) {
if (match?.[1] && match[2]) {
const key = match[1];
const value = match[2].trim();
@@ -92,7 +91,7 @@ class StorageService {
}
// Don't forget the last storage
if (currentStorage && currentStorage.name) {
if (currentStorage?.name) {
storages.push(this.finalizeStorage(currentStorage));
}
@@ -106,8 +105,8 @@ class StorageService {
return {
name: storage.name!,
type: storage.type!,
content: storage.content || [],
supportsBackup: storage.supportsBackup || false,
content: storage.content ?? [],
supportsBackup: storage.supportsBackup ?? false,
nodes: storage.nodes,
...Object.fromEntries(
Object.entries(storage).filter(([key]) =>
@@ -138,7 +137,7 @@ class StorageService {
let configContent = '';
await new Promise<void>((resolve, reject) => {
sshService.executeCommand(
void sshService.executeCommand(
server,
'cat /etc/pve/storage.cfg',
(data: string) => {
@@ -191,8 +190,8 @@ class StorageService {
}
return {
pbs_ip: (storage as any).server || null,
pbs_datastore: (storage as any).datastore || null,
pbs_ip: (storage as any).server ?? null,
pbs_datastore: (storage as any).datastore ?? null,
};
}
@@ -215,9 +214,7 @@ class StorageService {
let storageServiceInstance: StorageService | null = null;
export function getStorageService(): StorageService {
if (!storageServiceInstance) {
storageServiceInstance = new StorageService();
}
storageServiceInstance ??= new StorageService();
return storageServiceInstance;
}

View File

@@ -85,9 +85,10 @@ class SSHExecutionService {
* @param {Function} onData - Callback for data output
* @param {Function} onError - Callback for errors
* @param {Function} onExit - Callback for process exit
* @param {Object} [envVars] - Optional environment variables to pass to the script
* @returns {Promise<Object>} Process information
*/
async executeScript(server, scriptPath, onData, onError, onExit) {
async executeScript(server, scriptPath, onData, onError, onExit, envVars = {}) {
try {
await this.transferScriptsFolder(server, onData, onError);
@@ -98,8 +99,43 @@ class SSHExecutionService {
// Build SSH command based on authentication type
const { command, args } = this.buildSSHCommand(server);
// Format environment variables as var_name=value pairs
const envVarsString = Object.entries(envVars)
.map(([key, value]) => {
// Escape special characters in values
const escapedValue = String(value).replace(/'/g, "'\\''");
return `${key}='${escapedValue}'`;
})
.join(' ');
// Build the command with environment variables
let scriptCommand = `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`;
if (envVarsString) {
scriptCommand += ` && ${envVarsString} bash ${relativeScriptPath}`;
} else {
scriptCommand += ` && bash ${relativeScriptPath}`;
}
// Log the full command that will be executed
console.log('='.repeat(80));
console.log(`[SSH Execution] Executing on host: ${server.ip} (${server.name || 'Unnamed'})`);
console.log(`[SSH Execution] Script path: ${scriptPath}`);
console.log(`[SSH Execution] Relative script path: ${relativeScriptPath}`);
if (Object.keys(envVars).length > 0) {
console.log(`[SSH Execution] Environment variables (${Object.keys(envVars).length} vars):`);
Object.entries(envVars).forEach(([key, value]) => {
console.log(` ${key}=${String(value)}`);
});
} else {
console.log(`[SSH Execution] No environment variables provided`);
}
console.log(`[SSH Execution] Full command:`);
console.log(scriptCommand);
console.log('='.repeat(80));
// 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}`);
args.push(scriptCommand);
// Use ptySpawn for proper terminal emulation and color support
const sshCommand = ptySpawn(command, args, {

View File

@@ -22,7 +22,7 @@
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"jsx": "react-jsx",
"plugins": [
{
"name": "next"

Some files were not shown because too many files have changed in this diff Show More