Compare commits

...

13 Commits

Author SHA1 Message Date
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
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
21 changed files with 2264 additions and 2132 deletions

View File

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

View File

@@ -1 +1 @@
0.4.11 0.4.13

View File

@@ -43,6 +43,10 @@ const config = {
'http://192.168.*', 'http://192.168.*',
], ],
turbopack: {
// Disable Turbopack and use Webpack instead for compatibility
// This is necessary for server-side code that uses child_process
},
webpack: (config, { dev, isServer }) => { webpack: (config, { dev, isServer }) => {
if (dev && !isServer) { if (dev && !isServer) {
config.watchOptions = { config.watchOptions = {
@@ -50,12 +54,15 @@ const config = {
aggregateTimeout: 300, 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; 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) // Ignore TypeScript errors during build (they can be fixed separately)
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,

2694
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,11 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "next build", "build": "next build --webpack",
"check": "next lint && tsc --noEmit", "check": "next lint && tsc --noEmit",
"dev": "next dev", "dev": "next dev --webpack",
"dev:server": "node server.js", "dev:server": "node server.js",
"dev:next": "next dev", "dev:next": "next dev --webpack",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint", "lint": "next lint",
@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.18.0", "@prisma/client": "^6.19.0",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
@@ -43,14 +43,14 @@
"cron-validator": "^1.2.0", "cron-validator": "^1.2.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.553.0", "lucide-react": "^0.554.0",
"next": "^15.1.6", "next": "^16.0.4",
"node-cron": "^3.0.3", "node-cron": "^4.2.1",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^16.1.0",
"refractor": "^5.0.0", "refractor": "^5.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
@@ -74,10 +74,10 @@
"@types/react": "^19.2.4", "@types/react": "^19.2.4",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0", "@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^4.0.13",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^4.0.13",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^15.1.6", "eslint-config-next": "^16.0.4",
"jsdom": "^27.2.0", "jsdom": "^27.2.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
@@ -86,12 +86,15 @@
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.46.2",
"vitest": "^3.2.4" "vitest": "^4.0.13"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"
}, },
"packageManager": "npm@10.9.3", "packageManager": "npm@10.9.3",
"engines": {
"node": ">=24.0.0"
},
"overrides": { "overrides": {
"prismjs": "^1.30.0" "prismjs": "^1.30.0"
} }

View File

@@ -392,8 +392,6 @@ cleanup_lxc() {
# Python pip # Python pip
if command -v pip &>/dev/null; then $STD pip cache purge || true; fi if command -v pip &>/dev/null; then $STD pip cache purge || true; fi
# Python uv
if command -v uv &>/dev/null; then $STD uv cache clear || true; fi
# Node.js npm # Node.js npm
if command -v npm &>/dev/null; then $STD npm cache clean --force || true; fi if command -v npm &>/dev/null; then $STD npm cache clean --force || true; fi
# Node.js yarn # Node.js yarn
@@ -410,7 +408,6 @@ cleanup_lxc() {
if command -v composer &>/dev/null; then $STD composer clear-cache || true; fi if command -v composer &>/dev/null; then $STD composer clear-cache || true; fi
if command -v journalctl &>/dev/null; then if command -v journalctl &>/dev/null; then
$STD journalctl --rotate || true
$STD journalctl --vacuum-time=10m || true $STD journalctl --vacuum-time=10m || true
fi fi
msg_ok "Cleaned" msg_ok "Cleaned"

View File

@@ -79,15 +79,28 @@ class ScriptExecutionHandler {
* @param {import('http').Server} server * @param {import('http').Server} server
*/ */
constructor(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({ this.wss = new WebSocketServer({
server, noServer: true
path: '/ws/script-execution'
}); });
this.activeExecutions = new Map(); this.activeExecutions = new Map();
this.db = getDatabase(); this.db = getDatabase();
this.setupWebSocket(); 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 * Parse Container ID from terminal output
* @param {string} output - Terminal output to parse * @param {string} output - Terminal output to parse
@@ -1159,12 +1172,22 @@ app.prepare().then(() => {
const parsedUrl = parse(req.url || '', true); const parsedUrl = parse(req.url || '', true);
const { pathname, query } = parsedUrl; 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 // WebSocket upgrade will be handled by the WebSocket server
// Don't call handle() for this path - let WebSocketServer handle it
return; 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); await handle(req, res, parsedUrl);
} catch (err) { } catch (err) {
console.error('Error occurred handling', req.url, err); console.error('Error occurred handling', req.url, err);
@@ -1175,6 +1198,33 @@ app.prepare().then(() => {
// Create WebSocket handlers // Create WebSocket handlers
const scriptHandler = new ScriptExecutionHandler(httpServer); 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 // Note: TerminalHandler removed as it's not being used by the current application
httpServer httpServer

View File

@@ -187,9 +187,10 @@ export function CategorySidebar({
'Miscellaneous': 'box' '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 const sortedCategories = categories
.map(category => [category, categoryCounts[category] ?? 0] as const) .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]) => { .sort(([a, countA], [b, countB]) => {
if (countB !== countA) return countB - countA; if (countB !== countA) return countB - countA;
return a.localeCompare(b); return a.localeCompare(b);

View File

@@ -45,6 +45,7 @@ interface InstalledScript {
container_status?: 'running' | 'stopped' | 'unknown'; container_status?: 'running' | 'stopped' | 'unknown';
web_ui_ip: string | null; web_ui_ip: string | null;
web_ui_port: number | null; web_ui_port: number | null;
is_vm?: boolean;
} }
export function InstalledScriptsTab() { export function InstalledScriptsTab() {
@@ -1077,23 +1078,35 @@ export function InstalledScriptsTab() {
<h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2> <h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
{stats && ( {stats && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
<div className="bg-info/10 border border-info/20 p-4 rounded-lg text-center"> <div className="bg-info/10 border border-info/20 p-4 rounded-lg text-center">
<div className="text-2xl font-bold text-info">{stats.total}</div> <div className="text-2xl font-bold text-info">{stats.total}</div>
<div className="text-sm text-info/80">Total Installations</div> <div className="text-sm text-info/80">Total Installations</div>
</div> </div>
<div className="bg-success/10 border border-success/20 p-4 rounded-lg text-center"> <div className="bg-success/10 border border-success/20 p-4 rounded-lg text-center">
<div className="text-2xl font-bold text-success"> <div className="text-2xl font-bold text-success">
{scriptsWithStatus.filter(script => script.container_status === 'running').length} {scriptsWithStatus.filter(script => script.container_status === 'running' && !script.is_vm).length}
</div> </div>
<div className="text-sm text-success/80">Running LXC</div> <div className="text-sm text-success/80">Running LXC</div>
</div> </div>
<div className="bg-success/10 border border-success/20 p-4 rounded-lg text-center">
<div className="text-2xl font-bold text-success">
{scriptsWithStatus.filter(script => script.container_status === 'running' && script.is_vm).length}
</div>
<div className="text-sm text-success/80">Running VMs</div>
</div>
<div className="bg-error/10 border border-error/20 p-4 rounded-lg text-center"> <div className="bg-error/10 border border-error/20 p-4 rounded-lg text-center">
<div className="text-2xl font-bold text-error"> <div className="text-2xl font-bold text-error">
{scriptsWithStatus.filter(script => script.container_status === 'stopped').length} {scriptsWithStatus.filter(script => script.container_status === 'stopped' && !script.is_vm).length}
</div> </div>
<div className="text-sm text-error/80">Stopped LXC</div> <div className="text-sm text-error/80">Stopped LXC</div>
</div> </div>
<div className="bg-error/10 border border-error/20 p-4 rounded-lg text-center">
<div className="text-2xl font-bold text-error">
{scriptsWithStatus.filter(script => script.container_status === 'stopped' && script.is_vm).length}
</div>
<div className="text-sm text-error/80">Stopped VMs</div>
</div>
</div> </div>
)} )}
@@ -1527,7 +1540,18 @@ export function InstalledScriptsTab() {
</div> </div>
) : ( ) : (
<div> <div>
<div className="flex items-center gap-2">
{script.container_id && (
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
script.is_vm
? 'bg-purple-500/20 text-purple-600 dark:text-purple-400 border border-purple-500/30'
: 'bg-blue-500/20 text-blue-600 dark:text-blue-400 border border-blue-500/30'
}`}>
{script.is_vm ? 'VM' : 'LXC'}
</span>
)}
<div className="text-sm font-medium text-foreground">{script.script_name}</div> <div className="text-sm font-medium text-foreground">{script.script_name}</div>
</div>
<div className="text-sm text-muted-foreground">{script.script_path}</div> <div className="text-sm text-muted-foreground">{script.script_path}</div>
</div> </div>
)} )}
@@ -1683,7 +1707,7 @@ export function InstalledScriptsTab() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-48 bg-card border-border"> <DropdownMenuContent className="w-48 bg-card border-border">
{script.container_id && ( {script.container_id && !script.is_vm && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleUpdateScript(script)} onClick={() => handleUpdateScript(script)}
disabled={containerStatuses.get(script.id) === 'stopped'} disabled={containerStatuses.get(script.id) === 'stopped'}
@@ -1701,7 +1725,7 @@ export function InstalledScriptsTab() {
Backup Backup
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{script.container_id && script.execution_mode === 'ssh' && ( {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleOpenShell(script)} onClick={() => handleOpenShell(script)}
disabled={containerStatuses.get(script.id) === 'stopped'} disabled={containerStatuses.get(script.id) === 'stopped'}
@@ -1728,7 +1752,7 @@ export function InstalledScriptsTab() {
{autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'} {autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{script.container_id && script.execution_mode === 'ssh' && ( {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && (
<> <>
<DropdownMenuSeparator className="bg-border" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
@@ -1739,6 +1763,11 @@ export function InstalledScriptsTab() {
LXC Settings LXC Settings
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" /> <DropdownMenuSeparator className="bg-border" />
</>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<>
{script.is_vm && <DropdownMenuSeparator className="bg-border" />}
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')} onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'} disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ContextualHelpIcon } from './ContextualHelpIcon'; import { ContextualHelpIcon } from './ContextualHelpIcon';
@@ -9,6 +9,8 @@ export function ResyncButton() {
const [isResyncing, setIsResyncing] = useState(false); const [isResyncing, setIsResyncing] = useState(false);
const [lastSync, setLastSync] = useState<Date | null>(null); const [lastSync, setLastSync] = useState<Date | null>(null);
const [syncMessage, setSyncMessage] = useState<string | null>(null); const [syncMessage, setSyncMessage] = useState<string | null>(null);
const hasReloadedRef = useRef<boolean>(false);
const isUserInitiatedRef = useRef<boolean>(false);
const resyncMutation = api.scripts.resyncScripts.useMutation({ const resyncMutation = api.scripts.resyncScripts.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
@@ -16,24 +18,38 @@ export function ResyncButton() {
setLastSync(new Date()); setLastSync(new Date());
if (data.success) { if (data.success) {
setSyncMessage(data.message ?? 'Scripts synced successfully'); setSyncMessage(data.message ?? 'Scripts synced successfully');
// Reload the page after successful sync // Only reload if this was triggered by user action
if (isUserInitiatedRef.current && !hasReloadedRef.current) {
hasReloadedRef.current = true;
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 2000); // Wait 2 seconds to show the success message }, 2000); // Wait 2 seconds to show the success message
} else {
// Reset flag if reload didn't happen
isUserInitiatedRef.current = false;
}
} else { } else {
setSyncMessage(data.error ?? 'Failed to sync scripts'); setSyncMessage(data.error ?? 'Failed to sync scripts');
// Clear message after 3 seconds for errors // Clear message after 3 seconds for errors
setTimeout(() => setSyncMessage(null), 3000); setTimeout(() => setSyncMessage(null), 3000);
isUserInitiatedRef.current = false;
} }
}, },
onError: (error) => { onError: (error) => {
setIsResyncing(false); setIsResyncing(false);
setSyncMessage(`Error: ${error.message}`); setSyncMessage(`Error: ${error.message}`);
setTimeout(() => setSyncMessage(null), 3000); setTimeout(() => setSyncMessage(null), 3000);
isUserInitiatedRef.current = false;
}, },
}); });
const handleResync = async () => { const handleResync = async () => {
// Prevent multiple simultaneous sync operations
if (isResyncing) return;
// Mark as user-initiated before starting
isUserInitiatedRef.current = true;
hasReloadedRef.current = false;
setIsResyncing(true); setIsResyncing(true);
setSyncMessage(null); setSyncMessage(null);
resyncMutation.mutate(); resyncMutation.mutate();

View File

@@ -61,7 +61,11 @@ export function ScriptDetailModal({
isLoading: comparisonLoading, isLoading: comparisonLoading,
} = api.scripts.compareScriptContent.useQuery( } = api.scripts.compareScriptContent.useQuery(
{ slug: script?.slug ?? "" }, { slug: script?.slug ?? "" },
{ enabled: !!script && isOpen }, {
enabled: !!script && isOpen,
refetchOnMount: true,
staleTime: 0,
},
); );
// Load script mutation // Load script mutation
@@ -547,10 +551,10 @@ export function ScriptDetailModal({
</div> </div>
{scriptFilesData?.success && {scriptFilesData?.success &&
(scriptFilesData.ctExists || (scriptFilesData.ctExists ||
scriptFilesData.installExists) && scriptFilesData.installExists) && (
comparisonData?.success &&
!comparisonLoading && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{comparisonData?.success ? (
<>
<div <div
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`} className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
></div> ></div>
@@ -560,6 +564,47 @@ export function ScriptDetailModal({
? "Update available" ? "Update available"
: "Up to date"} : "Up to date"}
</span> </span>
</>
) : comparisonLoading ? (
<>
<div className="h-2 w-2 rounded-full bg-muted animate-pulse"></div>
<span>Checking for updates...</span>
</>
) : comparisonData?.error ? (
<>
<div className="h-2 w-2 rounded-full bg-destructive"></div>
<span className="text-destructive">Error: {comparisonData.error}</span>
</>
) : (
<>
<div className="h-2 w-2 rounded-full bg-muted"></div>
<span>Status: Unknown</span>
</>
)}
<button
onClick={() => void refetchComparison()}
disabled={comparisonLoading}
className="ml-2 p-1.5 rounded-md hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
title="Refresh comparison"
>
{comparisonLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
) : (
<svg
className="h-4 w-4 text-muted-foreground hover:text-foreground"
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>
)} )}
</div> </div>
@@ -837,7 +882,7 @@ export function ScriptDetailModal({
<TextViewer <TextViewer
scriptName={ scriptName={
script.install_methods 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("/") ?.script?.split("/")
.pop() ?? `${script.slug}.sh` .pop() ?? `${script.slug}.sh`
} }

View File

@@ -33,6 +33,7 @@ interface InstalledScript {
container_status?: 'running' | 'stopped' | 'unknown'; container_status?: 'running' | 'stopped' | 'unknown';
web_ui_ip: string | null; web_ui_ip: string | null;
web_ui_port: number | null; web_ui_port: number | null;
is_vm?: boolean;
} }
interface ScriptInstallationCardProps { interface ScriptInstallationCardProps {
@@ -300,7 +301,7 @@ export function ScriptInstallationCard({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-48 bg-card border-border"> <DropdownMenuContent className="w-48 bg-card border-border">
{script.container_id && ( {script.container_id && !script.is_vm && (
<DropdownMenuItem <DropdownMenuItem
onClick={onUpdate} onClick={onUpdate}
disabled={containerStatus === 'stopped'} disabled={containerStatus === 'stopped'}
@@ -318,7 +319,7 @@ export function ScriptInstallationCard({
Backup Backup
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{script.container_id && script.execution_mode === 'ssh' && ( {script.container_id && script.execution_mode === 'ssh' && !script.is_vm && (
<DropdownMenuItem <DropdownMenuItem
onClick={onShell} onClick={onShell}
disabled={containerStatus === 'stopped'} disabled={containerStatus === 'stopped'}

View File

@@ -14,9 +14,9 @@ interface TextViewerProps {
} }
interface ScriptContent { interface ScriptContent {
ctScript?: string; mainScript?: string;
installScript?: string; installScript?: string;
alpineCtScript?: string; alpineMainScript?: string;
alpineInstallScript?: string; alpineInstallScript?: string;
} }
@@ -24,18 +24,27 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
const [scriptContent, setScriptContent] = useState<ScriptContent>({}); const [scriptContent, setScriptContent] = useState<ScriptContent>({});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct'); const [activeTab, setActiveTab] = useState<'main' | 'install'>('main');
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default'); const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
// Extract slug from script name (remove .sh extension) // Extract slug from script name (remove .sh extension)
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, ''); const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
// Check if alpine variant exists // Get default and alpine install methods
const hasAlpineVariant = script?.install_methods?.some( const defaultMethod = script?.install_methods?.find(method => method.type === 'default');
method => method.type === 'alpine' && method.script?.startsWith('ct/') const alpineMethod = script?.install_methods?.find(method => method.type === 'alpine');
);
// Get script names for default and alpine versions // 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 defaultScriptName = scriptName.replace(/^alpine-/, '');
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`; const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
@@ -44,116 +53,72 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
setError(null); setError(null);
try { try {
// Build fetch requests for default version // Build fetch requests based on actual script paths from install_methods
const requests: Promise<Response>[] = []; const requests: Promise<Response>[] = [];
const requestTypes: Array<'default-main' | 'default-install' | 'alpine-main' | 'alpine-install'> = [];
// Default CT script // Default main script (ct/, vm/, tools/, etc.)
if (defaultScriptPath) {
requests.push( requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`) fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`)
); );
requestTypes.push('default-main');
}
// Tools, VM, VW scripts // Default install script (only for ct/ scripts)
requests.push( if (hasInstallScript && defaultScriptPath?.startsWith('ct/')) {
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( requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`) fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
); );
requestTypes.push('default-install');
}
// Alpine versions if variant exists // Alpine main script
if (hasAlpineVariant) { if (hasAlpineVariant && alpineScriptPath) {
requests.push( requests.push(
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`) 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( 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: `install/alpine-${slug}-install.sh` } }))}`)
); );
requestTypes.push('alpine-install');
} }
const responses = await Promise.allSettled(requests); const responses = await Promise.allSettled(requests);
const content: ScriptContent = {}; const content: ScriptContent = {};
let responseIndex = 0;
// Default CT script // Process responses based on their types
const ctResponse = responses[responseIndex]; await Promise.all(responses.map(async (response, index) => {
if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) { if (response.status === 'fulfilled' && response.value.ok) {
const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } }; try {
if (ctData.result?.data?.json?.success) { const data = await response.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
content.ctScript = ctData.result.data.json.content; 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 {
responseIndex++; // Ignore errors
// 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;
}
}
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); setScriptContent(content);
} catch (err) { } catch (err) {
@@ -161,7 +126,7 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]); }, [defaultScriptPath, alpineScriptPath, slug, hasAlpineVariant, hasInstallScript]);
useEffect(() => { useEffect(() => {
if (isOpen && scriptName) { if (isOpen && scriptName) {
@@ -207,16 +172,17 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
</Button> </Button>
</div> </div>
)} )}
{((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) || {((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) ||
(selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && ( (selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
variant={activeTab === 'ct' ? 'outline' : 'ghost'} variant={activeTab === 'main' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('ct')} onClick={() => setActiveTab('main')}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
CT Script Script
</Button> </Button>
{hasInstallScript && (
<Button <Button
variant={activeTab === 'install' ? 'outline' : 'ghost'} variant={activeTab === 'install' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('install')} onClick={() => setActiveTab('install')}
@@ -224,6 +190,7 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
> >
Install Script Install Script
</Button> </Button>
)}
</div> </div>
)} )}
</div> </div>
@@ -249,8 +216,8 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{activeTab === 'ct' && ( {activeTab === 'main' && (
selectedVersion === 'default' && scriptContent.ctScript ? ( selectedVersion === 'default' && scriptContent.mainScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
@@ -264,9 +231,9 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
> >
{scriptContent.ctScript} {scriptContent.mainScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? ( ) : selectedVersion === 'alpine' && scriptContent.alpineMainScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
@@ -280,12 +247,12 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
> >
{scriptContent.alpineCtScript} {scriptContent.alpineMainScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : ( ) : (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-lg text-muted-foreground"> <div className="text-lg text-muted-foreground">
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'} {selectedVersion === 'default' ? 'Default script not found' : 'Alpine script not found'}
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,176 @@
'use client';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { X, ExternalLink, Calendar, Tag, Loader2, 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 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<AlertTriangle className="h-6 w-6 text-warning" />
<div>
<h2 className="text-2xl font-bold text-card-foreground">Confirm Update</h2>
<p className="text-sm text-muted-foreground mt-1">
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-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Version Info */}
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-card-foreground">
{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="flex items-center gap-4 text-sm text-muted-foreground mb-3">
<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-sm text-muted-foreground">
<span>Updating from </span>
<span className="font-medium text-card-foreground">v{currentVersion}</span>
<span> to </span>
<span className="font-medium text-card-foreground">v{latestVersion}</span>
</div>
</div>
{/* Changelog */}
{releaseInfo.body ? (
<div className="border rounded-lg p-6 border-border bg-card">
<h4 className="text-md font-semibold text-card-foreground mb-4">Changelog</h4>
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({children}) => <h1 className="text-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>,
h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>,
h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>,
p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>,
ul: ({children}) => <ul className="list-disc list-inside text-card-foreground mb-3 space-y-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal list-inside text-card-foreground mb-3 space-y-1">{children}</ol>,
li: ({children}) => <li className="text-card-foreground">{children}</li>,
a: ({href, children}) => <a href={href} className="text-info hover:text-info/80 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
strong: ({children}) => <strong className="font-semibold text-card-foreground">{children}</strong>,
em: ({children}) => <em className="italic text-card-foreground">{children}</em>,
}}
>
{releaseInfo.body}
</ReactMarkdown>
</div>
</div>
) : (
<div className="border rounded-lg p-6 border-border bg-card">
<p className="text-muted-foreground">No changelog available for this release.</p>
</div>
)}
{/* Warning */}
<div className="bg-warning/10 border border-warning/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning mt-0.5 flex-shrink-0" />
<div className="text-sm text-card-foreground">
<p className="font-medium mb-1">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="flex items-center justify-between p-6 border-t border-border bg-muted/30">
<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 { Badge } from "./ui/badge";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon"; import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { UpdateConfirmationModal } from "./UpdateConfirmationModal";
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react"; import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
interface VersionDisplayProps { interface VersionDisplayProps {
onOpenReleaseNotes?: () => void; onOpenReleaseNotes?: () => void;
@@ -85,8 +86,12 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
const [updateLogs, setUpdateLogs] = useState<string[]>([]); const [updateLogs, setUpdateLogs] = useState<string[]>([]);
const [shouldSubscribe, setShouldSubscribe] = useState(false); const [shouldSubscribe, setShouldSubscribe] = useState(false);
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null); const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
const lastLogTimeRef = useRef<number>(Date.now()); const lastLogTimeRef = useRef<number>(Date.now());
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null); const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
const hasReloadedRef = useRef<boolean>(false);
const isUpdatingRef = useRef<boolean>(false);
const isNetworkErrorRef = useRef<boolean>(false);
const executeUpdate = api.version.executeUpdate.useMutation({ const executeUpdate = api.version.executeUpdate.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
@@ -98,11 +103,13 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
setUpdateLogs(['Update started...']); setUpdateLogs(['Update started...']);
} else { } else {
setIsUpdating(false); setIsUpdating(false);
setShouldSubscribe(false); // Reset subscription on failure
} }
}, },
onError: (error) => { onError: (error) => {
setUpdateResult({ success: false, message: error.message }); setUpdateResult({ success: false, message: error.message });
setIsUpdating(false); setIsUpdating(false);
setShouldSubscribe(false); // Reset subscription on error
} }
}); });
@@ -113,63 +120,49 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
refetchIntervalInBackground: true, refetchIntervalInBackground: true,
}); });
// Update logs when data changes
useEffect(() => {
if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
if (updateLogsData.isComplete) {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
}
}
}, [updateLogsData]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
if (!shouldSubscribe) return;
// Only use this as a fallback - the main trigger should be completion detection
const checkInterval = setInterval(() => {
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
// Only start reconnection if we've been updating for at least 3 minutes
// and no logs for 60 seconds (very conservative fallback)
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
setIsNetworkError(true);
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
// Start trying to reconnect
startReconnectAttempts();
}
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
// Attempt to reconnect and reload page when server is back // Attempt to reconnect and reload page when server is back
const startReconnectAttempts = () => { // Memoized with useCallback to prevent recreation on every render
if (reconnectIntervalRef.current) return; // 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 to prevent false triggers from stale data
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current) {
return;
}
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']); setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
reconnectIntervalRef.current = setInterval(() => { reconnectIntervalRef.current = setInterval(() => {
void (async () => { 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) {
// Clear interval if we're no longer updating
if (!isUpdatingRef.current && reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
return;
}
try { try {
// Try to fetch the root path to check if server is back // Try to fetch the root path to check if server is back
const response = await fetch('/', { method: 'HEAD' }); const response = await fetch('/', { method: 'HEAD' });
if (response.ok || response.status === 200) { if (response.ok || response.status === 200) {
// Double-check we're still updating before reloading
if (!isUpdatingRef.current || hasReloadedRef.current) {
return;
}
// Mark that we're about to reload to prevent multiple reloads
hasReloadedRef.current = true;
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']); setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
// Clear interval and reload // Clear interval and reload
if (reconnectIntervalRef.current) { if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
} }
setTimeout(() => { setTimeout(() => {
@@ -181,18 +174,101 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
} }
})(); })();
}, 2000); }, 2000);
}; }, []); // Empty deps - only uses refs which are stable
// Cleanup reconnect interval on unmount // Update logs when data changes
useEffect(() => { useEffect(() => {
// CRITICAL: Only process update logs if we're actually updating
// This prevents stale isComplete data from triggering reloads when not updating
if (!isUpdating) {
return;
}
if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
// CRITICAL: Only process isComplete if we're actually updating
// Double-check isUpdating state to prevent false triggers
if (updateLogsData.isComplete && isUpdating) {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
}
}
}, [updateLogsData, startReconnectAttempts, isUpdating]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
// 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
// and no logs for 60 seconds (very conservative fallback)
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
// Additional guard: check refs again before triggering
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current) {
setIsNetworkError(true);
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
// Start trying to reconnect
startReconnectAttempts();
}
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, startReconnectAttempts]);
// Keep refs in sync with state
useEffect(() => {
isUpdatingRef.current = isUpdating;
}, [isUpdating]);
useEffect(() => {
isNetworkErrorRef.current = isNetworkError;
}, [isNetworkError]);
// 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;
}
// Reset subscription to prevent stale polling
setShouldSubscribe(false);
}
return () => { return () => {
if (reconnectIntervalRef.current) { if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
} }
}; };
}, []); }, [isUpdating]);
const handleUpdate = () => { const handleUpdate = () => {
// Show confirmation modal instead of starting update directly
setShowUpdateConfirmation(true);
};
const handleConfirmUpdate = () => {
// Close the confirmation modal
setShowUpdateConfirmation(false);
// Start the actual update process
setIsUpdating(true); setIsUpdating(true);
setUpdateResult(null); setUpdateResult(null);
setIsNetworkError(false); setIsNetworkError(false);
@@ -200,6 +276,12 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
setShouldSubscribe(false); setShouldSubscribe(false);
setUpdateStartTime(Date.now()); setUpdateStartTime(Date.now());
lastLogTimeRef.current = Date.now(); lastLogTimeRef.current = Date.now();
hasReloadedRef.current = false; // Reset reload flag when starting new update
// Clear any existing reconnect interval
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null;
}
executeUpdate.mutate(); executeUpdate.mutate();
}; };
@@ -233,6 +315,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
{/* Loading overlay */} {/* Loading overlay */}
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />} {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"> <div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
<Badge <Badge
variant={isUpToDate ? "default" : "secondary"} variant={isUpToDate ? "default" : "secondary"}

View File

@@ -95,6 +95,13 @@ export default function Home() {
downloaded: (() => { downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0; 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 // First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, any>(); const scriptMap = new Map<string, any>();
@@ -110,13 +117,36 @@ export default function Home() {
const localScripts = localScriptsData.scripts ?? []; const localScripts = localScriptsData.scripts ?? [];
// Count scripts that are both in deduplicated GitHub data and have local versions // Count scripts that are both in deduplicated GitHub data and have local versions
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
return deduplicatedGithubScripts.filter(script => { return deduplicatedGithubScripts.filter(script => {
if (!script?.name) return false; if (!script?.name) return false;
// Check if there's a corresponding local script
return localScripts.some(local => { return localScripts.some(local => {
if (!local?.name) return false; if (!local?.name) return false;
const localName = local.name.replace(/\.sh$/, '');
return localName.toLowerCase() === script.name.toLowerCase() || // Primary: Exact slug-to-slug matching (most reliable)
localName.toLowerCase() === (script.slug ?? '').toLowerCase(); 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) === normalizeId(script.slug)) {
return true;
}
}
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
if (matchesInstallBasename) return true;
// Tertiary: Normalized filename to normalized slug matching
if (script.slug && normalizeId(local.name) === normalizeId(script.slug)) {
return true;
}
return false;
}); });
}).length; }).length;
})(), })(),

View File

@@ -383,6 +383,88 @@ async function tryLVMResize(
); );
} }
// Helper function to determine if a container is a VM or LXC
async function isVM(scriptId: number, containerId: string, serverId: number | null): Promise<boolean> {
const db = getDatabase();
// Method 1: Check if LXCConfig exists (if exists, it's an LXC container)
const lxcConfig = await db.getLXCConfigByScriptId(scriptId);
if (lxcConfig) {
return false; // Has LXCConfig, so it's an LXC container
}
// Method 2: If no LXCConfig, check config file paths on server
if (!serverId) {
// Can't determine without server, default to false (LXC) for safety
return false;
}
try {
const server = await db.getServerById(serverId);
if (!server) {
return false; // Default to LXC if server not found
}
// Import SSH services
const { default: SSHService } = await import('~/server/ssh-service');
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
const sshService = new SSHService();
const sshExecutionService = new SSHExecutionService();
// Test SSH connection
const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) {
return false; // Default to LXC if SSH fails
}
// Check both config file paths
const vmConfigPath = `/etc/pve/qemu-server/${containerId}.conf`;
const lxcConfigPath = `/etc/pve/lxc/${containerId}.conf`;
// Check VM config file
let vmConfigExists = false;
await new Promise<void>((resolve) => {
void sshExecutionService.executeCommand(
server as Server,
`test -f "${vmConfigPath}" && echo "exists" || echo "not_exists"`,
(data: string) => {
if (data.includes('exists')) {
vmConfigExists = true;
}
},
() => resolve(),
() => resolve()
);
});
if (vmConfigExists) {
return true; // VM config file exists
}
// Check LXC config file
let lxcConfigExists = false;
await new Promise<void>((resolve) => {
void sshExecutionService.executeCommand(
server as Server,
`test -f "${lxcConfigPath}" && echo "exists" || echo "not_exists"`,
(data: string) => {
if (data.includes('exists')) {
lxcConfigExists = true;
}
},
() => resolve(),
() => resolve()
);
});
// If LXC config exists, it's an LXC container
return !lxcConfigExists; // Return true if it's a VM (neither config exists defaults to false/LXC)
} catch (error) {
console.error('Error determining container type:', error);
return false; // Default to LXC on error
}
}
export const installedScriptsRouter = createTRPCRouter({ export const installedScriptsRouter = createTRPCRouter({
// Get all installed scripts // Get all installed scripts
@@ -393,7 +475,14 @@ export const installedScriptsRouter = createTRPCRouter({
const scripts = await db.getAllInstalledScripts(); const scripts = await db.getAllInstalledScripts();
// Transform scripts to flatten server data for frontend compatibility // Transform scripts to flatten server data for frontend compatibility
const transformedScripts = scripts.map(script => ({ const transformedScripts = await Promise.all(scripts.map(async (script) => {
// Determine if it's a VM or LXC
let is_vm = false;
if (script.container_id && script.server_id) {
is_vm = await isVM(script.id, script.container_id, script.server_id);
}
return {
...script, ...script,
server_name: script.server?.name ?? null, server_name: script.server?.name ?? null,
server_ip: script.server?.ip ?? null, server_ip: script.server?.ip ?? null,
@@ -404,7 +493,9 @@ export const installedScriptsRouter = createTRPCRouter({
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null, server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
server_ssh_port: script.server?.ssh_port ?? null, server_ssh_port: script.server?.ssh_port ?? null,
server_color: script.server?.color ?? null, server_color: script.server?.color ?? null,
is_vm,
server: undefined // Remove nested server object server: undefined // Remove nested server object
};
})); }));
return { return {
@@ -430,7 +521,14 @@ export const installedScriptsRouter = createTRPCRouter({
const scripts = await db.getInstalledScriptsByServer(input.serverId); const scripts = await db.getInstalledScriptsByServer(input.serverId);
// Transform scripts to flatten server data for frontend compatibility // Transform scripts to flatten server data for frontend compatibility
const transformedScripts = scripts.map(script => ({ const transformedScripts = await Promise.all(scripts.map(async (script) => {
// Determine if it's a VM or LXC
let is_vm = false;
if (script.container_id && script.server_id) {
is_vm = await isVM(script.id, script.container_id, script.server_id);
}
return {
...script, ...script,
server_name: script.server?.name ?? null, server_name: script.server?.name ?? null,
server_ip: script.server?.ip ?? null, server_ip: script.server?.ip ?? null,
@@ -441,7 +539,9 @@ export const installedScriptsRouter = createTRPCRouter({
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null, server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
server_ssh_port: script.server?.ssh_port ?? null, server_ssh_port: script.server?.ssh_port ?? null,
server_color: script.server?.color ?? null, server_color: script.server?.color ?? null,
is_vm,
server: undefined // Remove nested server object server: undefined // Remove nested server object
};
})); }));
return { return {
@@ -472,6 +572,12 @@ export const installedScriptsRouter = createTRPCRouter({
script: null script: null
}; };
} }
// Determine if it's a VM or LXC
let is_vm = false;
if (script.container_id && script.server_id) {
is_vm = await isVM(script.id, script.container_id, script.server_id);
}
// Transform script to flatten server data for frontend compatibility // Transform script to flatten server data for frontend compatibility
const transformedScript = { const transformedScript = {
...script, ...script,
@@ -484,6 +590,7 @@ export const installedScriptsRouter = createTRPCRouter({
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null, server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
server_ssh_port: script.server?.ssh_port ?? null, server_ssh_port: script.server?.ssh_port ?? null,
server_color: script.server?.color ?? null, server_color: script.server?.color ?? null,
is_vm,
server: undefined // Remove nested server object server: undefined // Remove nested server object
}; };
@@ -677,113 +784,159 @@ export const installedScriptsRouter = createTRPCRouter({
} }
// Use the working approach - manual loop through all config files // Get containers from pct list and VMs from qm list
const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`;
let detectedContainers: any[] = []; let detectedContainers: any[] = [];
// Helper function to parse list output and extract IDs
const parseListOutput = (output: string, isVM: boolean): string[] => {
const ids: string[] = [];
const lines = output.split('\n').filter(line => line.trim());
let commandOutput = ''; for (const line of lines) {
// Skip header lines
if (line.includes('VMID') || line.includes('CTID')) continue;
await new Promise<void>((resolve, reject) => { // Extract first column (ID)
const parts = line.trim().split(/\s+/);
if (parts.length > 0) {
const id = parts[0]?.trim();
// Validate ID format (3-4 digits typically)
if (id && /^\d{3,4}$/.test(id)) {
ids.push(id);
}
}
}
void sshExecutionService.executeCommand( return ids;
};
server as Server, // Helper function to check config file for community-script tag and extract hostname/name
command, const checkConfigAndExtractInfo = async (id: string, isVM: boolean): Promise<any> => {
(data: string) => { const configPath = isVM
commandOutput += data; ? `/etc/pve/qemu-server/${id}.conf`
}, : `/etc/pve/lxc/${id}.conf`;
(error: string) => {
console.error('Command error:', error);
},
(_exitCode: number) => {
// Parse the complete output to get config file paths that contain community-script tag
const configFiles = commandOutput.split('\n')
.filter((line: string) => line.trim())
.map((line: string) => line.trim())
.filter((line: string) => line.endsWith('.conf'));
// Process each config file to extract hostname
const processPromises = configFiles.map(async (configPath: string) => {
try {
const containerId = configPath.split('/').pop()?.replace('.conf', '');
if (!containerId) return null;
// Read the config file content
const readCommand = `cat "${configPath}" 2>/dev/null`; const readCommand = `cat "${configPath}" 2>/dev/null`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return return new Promise<any>((resolve) => {
return new Promise<any>((readResolve) => { let configData = '';
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
server as Server, server as Server,
readCommand, readCommand,
(configData: string) => { (data: string) => {
// Parse config file for hostname configData += data;
},
(_error: string) => {
// Config file doesn't exist or can't be read
resolve(null);
},
(_exitCode: number) => {
// Check if config contains community-script tag
if (!configData.includes('community-script')) {
resolve(null);
return;
}
// Extract hostname (for containers) or name (for VMs)
const lines = configData.split('\n'); const lines = configData.split('\n');
let hostname = ''; let hostname = '';
let name = '';
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
if (trimmedLine.startsWith('hostname:')) { if (trimmedLine.startsWith('hostname:')) {
hostname = trimmedLine.substring(9).trim(); hostname = trimmedLine.substring(9).trim();
break; } else if (trimmedLine.startsWith('name:')) {
name = trimmedLine.substring(5).trim();
} }
} }
if (hostname) { // Use hostname for containers, name for VMs
// Parse full config and store in database const displayName = isVM ? name : hostname;
const parsedConfig = parseRawConfig(configData);
const configHash = calculateConfigHash(configData);
const container = { if (displayName) {
containerId, // Parse full config and store in database (only for containers)
hostname, let parsedConfig = null;
let configHash = null;
if (!isVM) {
parsedConfig = parseRawConfig(configData);
configHash = calculateConfigHash(configData);
}
resolve({
containerId: id,
hostname: displayName,
configPath, configPath,
isVM,
serverId: Number((server as any).id), serverId: Number((server as any).id),
serverName: (server as any).name, serverName: (server as any).name,
parsedConfig: { parsedConfig: parsedConfig ? {
...parsedConfig, ...parsedConfig,
config_hash: configHash, config_hash: configHash,
synced_at: new Date() synced_at: new Date()
} } : null
}; });
readResolve(container);
} else { } else {
readResolve(null); resolve(null);
} }
}
);
});
};
// Get containers from pct list
let pctOutput = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server as Server,
'pct list',
(data: string) => {
pctOutput += data;
}, },
(readError: string) => { (error: string) => {
console.error(`Error reading config file ${configPath}:`, readError); console.error('pct list error:', error);
readResolve(null); reject(new Error(`pct list failed: ${error}`));
}, },
(_exitCode: number) => { (_exitCode: number) => {
readResolve(null); resolve();
} }
); );
}); });
} catch (error) {
console.error(`Error processing config file ${configPath}:`, error);
return null;
}
});
// Wait for all config files to be processed // Get VMs from qm list
void Promise.all(processPromises).then((results) => { let qmOutput = '';
detectedContainers = results.filter(result => result !== null); await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server as Server,
'qm list',
(data: string) => {
qmOutput += data;
},
(error: string) => {
console.error('qm list error:', error);
reject(new Error(`qm list failed: ${error}`));
},
(_exitCode: number) => {
resolve(); resolve();
}).catch((error) => {
console.error('Error processing config files:', error);
reject(new Error(`Error processing config files: ${error}`));
});
} }
); );
}); });
// Parse IDs from both lists
const containerIds = parseListOutput(pctOutput, false);
const vmIds = parseListOutput(qmOutput, true);
// Check each container/VM for community-script tag
const checkPromises = [
...containerIds.map(id => checkConfigAndExtractInfo(id, false)),
...vmIds.map(id => checkConfigAndExtractInfo(id, true))
];
const results = await Promise.all(checkPromises);
detectedContainers = results.filter(result => result !== null);
// Get existing scripts to check for duplicates // Get existing scripts to check for duplicates
const existingScripts = await db.getAllInstalledScripts(); const existingScripts = await db.getAllInstalledScripts();
@@ -816,11 +969,11 @@ export const installedScriptsRouter = createTRPCRouter({
server_id: container.serverId, server_id: container.serverId,
execution_mode: 'ssh', execution_mode: 'ssh',
status: 'success', status: 'success',
output_log: `Auto-detected from LXC config: ${container.configPath}` output_log: `Auto-detected from ${container.isVM ? 'VM' : 'LXC'} config: ${container.configPath}`
}); });
// Store LXC config in database // Store LXC config in database (only for containers, not VMs)
if (container.parsedConfig) { if (container.parsedConfig && !container.isVM) {
await db.createLXCConfig(result.id, container.parsedConfig); await db.createLXCConfig(result.id, container.parsedConfig);
} }
@@ -836,8 +989,8 @@ export const installedScriptsRouter = createTRPCRouter({
} }
const message = skippedScripts.length > 0 const message = skippedScripts.length > 0
? `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.` ? `Auto-detection completed. Found ${detectedContainers.length} containers/VMs with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
: `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts.`; : `Auto-detection completed. Found ${detectedContainers.length} containers/VMs with community-script tag. Added ${createdScripts.length} new scripts.`;
return { return {
success: true, success: true,
@@ -920,11 +1073,32 @@ export const installedScriptsRouter = createTRPCRouter({
continue; continue;
} }
// Get all existing containers from pct list (more reliable than checking config files) // Helper function to parse list output and extract IDs
const listCommand = 'pct list'; const parseListOutput = (output: string): Set<string> => {
let listOutput = ''; const ids = new Set<string>();
const lines = output.split('\n').filter(line => line.trim());
const existingContainerIds = await new Promise<Set<string>>((resolve, reject) => { for (const line of lines) {
// Skip header lines
if (line.includes('VMID') || line.includes('CTID')) continue;
// Extract first column (ID)
const parts = line.trim().split(/\s+/);
if (parts.length > 0) {
const id = parts[0]?.trim();
// Validate ID format (3-4 digits typically)
if (id && /^\d{3,4}$/.test(id)) {
ids.add(id);
}
}
}
return ids;
};
// Get all existing containers from pct list
let pctOutput = '';
const existingContainerIds = await new Promise<Set<string>>((resolve) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`); console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`);
resolve(new Set()); // Treat timeout as no containers found resolve(new Set()); // Treat timeout as no containers found
@@ -932,9 +1106,9 @@ export const installedScriptsRouter = createTRPCRouter({
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
server as Server, server as Server,
listCommand, 'pct list',
(data: string) => { (data: string) => {
listOutput += data; pctOutput += data;
}, },
(error: string) => { (error: string) => {
console.error(`cleanupOrphanedScripts: error getting container list from server ${String((server as any).name)}:`, error); console.error(`cleanupOrphanedScripts: error getting container list from server ${String((server as any).name)}:`, error);
@@ -943,58 +1117,95 @@ export const installedScriptsRouter = createTRPCRouter({
}, },
(_exitCode: number) => { (_exitCode: number) => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(parseListOutput(pctOutput));
// Parse pct list output to extract container IDs
const containerIds = new Set<string>();
const lines = listOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
// pct list format: CTID Status Name
// Skip header line if present
if (line.includes('CTID') || line.includes('VMID')) continue;
const parts = line.trim().split(/\s+/);
if (parts.length > 0) {
const containerId = parts[0]?.trim();
if (containerId && /^\d{3,4}$/.test(containerId)) {
containerIds.add(containerId);
}
}
}
resolve(containerIds);
} }
); );
}); });
// Check each script against the list of existing containers // Get all existing VMs from qm list
let qmOutput = '';
const existingVMIds = await new Promise<Set<string>>((resolve) => {
const timeout = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while getting VM list from server ${String((server as any).name)}`);
resolve(new Set()); // Treat timeout as no VMs found
}, 20000);
void sshExecutionService.executeCommand(
server as Server,
'qm list',
(data: string) => {
qmOutput += data;
},
(error: string) => {
console.error(`cleanupOrphanedScripts: error getting VM list from server ${String((server as any).name)}:`, error);
clearTimeout(timeout);
resolve(new Set()); // Treat error as no VMs found
},
(_exitCode: number) => {
clearTimeout(timeout);
resolve(parseListOutput(qmOutput));
}
);
});
// Combine both sets - an ID exists if it's in either list
const existingIds = new Set<string>([...existingContainerIds, ...existingVMIds]);
// Check each script against the list of existing containers and VMs
for (const scriptData of serverScripts) { for (const scriptData of serverScripts) {
try { try {
const containerId = String(scriptData.container_id).trim(); const containerId = String(scriptData.container_id).trim();
// Check if container exists in pct list // Check if ID exists in either pct list (containers) or qm list (VMs)
if (!existingContainerIds.has(containerId)) { if (!existingIds.has(containerId)) {
// Also verify config file doesn't exist as a double-check // Also verify config file doesn't exist as a double-check
const checkCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`; // Check both container and VM config paths
const checkContainerCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
const checkVMCommand = `test -f "/etc/pve/qemu-server/${containerId}.conf" && echo "exists" || echo "not_found"`;
const configExists = await new Promise<boolean>((resolve) => { const configExists = await new Promise<boolean>((resolve) => {
let combinedOutput = ''; let combinedOutput = '';
let resolved = false; let resolved = false;
let checksCompleted = 0;
const finish = () => { const finish = () => {
if (resolved) return; if (resolved) return;
checksCompleted++;
if (checksCompleted === 2) {
resolved = true; resolved = true;
clearTimeout(timer);
const out = combinedOutput.trim(); const out = combinedOutput.trim();
resolve(out.includes('exists')); resolve(out.includes('exists'));
}
}; };
const timer = setTimeout(() => { const timer = setTimeout(() => {
finish(); if (!resolved) {
resolved = true;
const out = combinedOutput.trim();
resolve(out.includes('exists'));
}
}, 10000); }, 10000);
// Check container config
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
server as Server, server as Server,
checkCommand, checkContainerCommand,
(data: string) => {
combinedOutput += data;
},
(_error: string) => {
// Ignore errors, just check output
},
(_exitCode: number) => {
finish();
}
);
// Check VM config
void sshExecutionService.executeCommand(
server as Server,
checkVMCommand,
(data: string) => { (data: string) => {
combinedOutput += data; combinedOutput += data;
}, },
@@ -1002,20 +1213,19 @@ export const installedScriptsRouter = createTRPCRouter({
// Ignore errors, just check output // Ignore errors, just check output
}, },
(_exitCode: number) => { (_exitCode: number) => {
clearTimeout(timer);
finish(); finish();
} }
); );
}); });
// If container is not in pct list AND config file doesn't exist, it's orphaned // If ID is not in either list AND config file doesn't exist, it's orphaned
if (!configExists) { if (!configExists) {
console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (container ${containerId}) from server ${String((server as any).name)}`); console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (ID ${containerId}) from server ${String((server as any).name)}`);
await db.deleteInstalledScript(Number(scriptData.id)); await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name)); deletedScripts.push(String(scriptData.script_name));
} else { } else {
// Config exists but not in pct list - might be in a transitional state, log but don't delete // Config exists but not in lists - might be in a transitional state, log but don't delete
console.warn(`cleanupOrphanedScripts: Container ${containerId} (${String(scriptData.script_name)}) config exists but not in pct list - may be in transitional state`); console.warn(`cleanupOrphanedScripts: Container/VM ${containerId} (${String(scriptData.script_name)}) config exists but not in pct/qm list - may be in transitional state`);
} }
} }
} catch (error) { } catch (error) {
@@ -1080,27 +1290,74 @@ export const installedScriptsRouter = createTRPCRouter({
continue; continue;
} }
// Run pct list to get all container statuses at once // Helper function to parse list output and extract statuses
const listCommand = 'pct list'; const parseListStatuses = (output: string): Record<string, 'running' | 'stopped' | 'unknown'> => {
let listOutput = ''; const statuses: Record<string, 'running' | 'stopped' | 'unknown'> = {};
const lines = output.split('\n').filter(line => line.trim());
// Find header line to determine column positions
let statusColumnIndex = 1; // Default to second column
for (const line of lines) {
if (line.includes('STATUS')) {
// Parse header to find STATUS column index
const headerParts = line.trim().split(/\s+/);
const statusIndex = headerParts.findIndex(part => part.includes('STATUS'));
if (statusIndex >= 0) {
statusColumnIndex = statusIndex;
}
break;
}
}
for (const line of lines) {
// Skip header lines
if (line.includes('VMID') || line.includes('CTID') || line.includes('STATUS')) continue;
// Parse line
const parts = line.trim().split(/\s+/);
if (parts.length > statusColumnIndex) {
const id = parts[0]?.trim();
const status = parts[statusColumnIndex]?.trim().toLowerCase();
if (id && /^\d+$/.test(id)) { // Validate ID is numeric
// Map status to our status format
let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown';
if (status === 'running') {
mappedStatus = 'running';
} else if (status === 'stopped') {
mappedStatus = 'stopped';
}
// All other statuses (paused, locked, suspended, etc.) map to 'unknown'
statuses[id] = mappedStatus;
}
}
}
return statuses;
};
// Run pct list to get all container statuses
let pctOutput = '';
// Add timeout to prevent hanging connections // Add timeout to prevent hanging connections
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('SSH command timeout after 30 seconds')), 30000); setTimeout(() => reject(new Error('SSH command timeout after 30 seconds')), 30000);
}); });
try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve, reject) => { new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
server as Server, server as Server,
listCommand, 'pct list',
(data: string) => { (data: string) => {
listOutput += data; pctOutput += data;
}, },
(error: string) => { (error: string) => {
console.error(`pct list error on server ${(server as any).name}:`, error); console.error(`pct list error on server ${(server as any).name}:`, error);
reject(new Error(error)); // Don't reject, just continue with empty output
resolve();
}, },
(_exitCode: number) => { (_exitCode: number) => {
resolve(); resolve();
@@ -1109,30 +1366,44 @@ export const installedScriptsRouter = createTRPCRouter({
}), }),
timeoutPromise timeoutPromise
]); ]);
} catch (error) {
// Parse pct list output console.error(`Timeout or error getting pct list from server ${(server as any).name}:`, error);
const lines = listOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
// pct list format: CTID Status Name
// Example: "100 running my-container"
const parts = line.trim().split(/\s+/);
if (parts.length >= 3) {
const containerId = parts[0];
const status = parts[1];
if (containerId && status) {
// Map pct list status to our status
let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown';
if (status === 'running') {
mappedStatus = 'running';
} else if (status === 'stopped') {
mappedStatus = 'stopped';
} }
statusMap[containerId] = mappedStatus; // Run qm list to get all VM statuses
} let qmOutput = '';
try {
await Promise.race([
new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server as Server,
'qm list',
(data: string) => {
qmOutput += data;
},
(error: string) => {
console.error(`qm list error on server ${(server as any).name}:`, error);
// Don't reject, just continue with empty output
resolve();
},
(_exitCode: number) => {
resolve();
} }
);
}),
timeoutPromise
]);
} catch (error) {
console.error(`Timeout or error getting qm list from server ${(server as any).name}:`, error);
} }
// Parse both outputs and combine into statusMap
const containerStatuses = parseListStatuses(pctOutput);
const vmStatuses = parseListStatuses(qmOutput);
// Merge both status maps (VMs will overwrite containers if same ID, but that's unlikely)
Object.assign(statusMap, containerStatuses, vmStatuses);
} catch (error) { } catch (error) {
console.error(`Error processing server ${(server as any).name}:`, error); console.error(`Error processing server ${(server as any).name}:`, error);
} }
@@ -1207,8 +1478,13 @@ export const installedScriptsRouter = createTRPCRouter({
}; };
} }
// Check container status // Determine if it's a VM or LXC
const statusCommand = `pct status ${scriptData.container_id}`; const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
// Check container status (use qm for VMs, pct for LXC)
const statusCommand = vm
? `qm status ${scriptData.container_id}`
: `pct status ${scriptData.container_id}`;
let statusOutput = ''; let statusOutput = '';
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -1305,8 +1581,13 @@ export const installedScriptsRouter = createTRPCRouter({
}; };
} }
// Execute control command // Determine if it's a VM or LXC
const controlCommand = `pct ${input.action} ${scriptData.container_id}`; const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
// Execute control command (use qm for VMs, pct for LXC)
const controlCommand = vm
? `qm ${input.action} ${scriptData.container_id}`
: `pct ${input.action} ${scriptData.container_id}`;
let commandOutput = ''; let commandOutput = '';
let commandError = ''; let commandError = '';
@@ -1396,8 +1677,13 @@ export const installedScriptsRouter = createTRPCRouter({
}; };
} }
// Determine if it's a VM or LXC
const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
// First check if container is running and stop it if necessary // First check if container is running and stop it if necessary
const statusCommand = `pct status ${scriptData.container_id}`; const statusCommand = vm
? `qm status ${scriptData.container_id}`
: `pct status ${scriptData.container_id}`;
let statusOutput = ''; let statusOutput = '';
try { try {
@@ -1420,8 +1706,10 @@ export const installedScriptsRouter = createTRPCRouter({
// Check if container is running // Check if container is running
if (statusOutput.includes('status: running')) { if (statusOutput.includes('status: running')) {
// Stop the container first // Stop the container first (use qm for VMs, pct for LXC)
const stopCommand = `pct stop ${scriptData.container_id}`; const stopCommand = vm
? `qm stop ${scriptData.container_id}`
: `pct stop ${scriptData.container_id}`;
let stopOutput = ''; let stopOutput = '';
let stopError = ''; let stopError = '';
@@ -1451,8 +1739,10 @@ export const installedScriptsRouter = createTRPCRouter({
} }
// Execute destroy command // Execute destroy command (use qm for VMs, pct for LXC)
const destroyCommand = `pct destroy ${scriptData.container_id}`; const destroyCommand = vm
? `qm destroy ${scriptData.container_id}`
: `pct destroy ${scriptData.container_id}`;
let commandOutput = ''; let commandOutput = '';
let commandError = ''; let commandError = '';

View File

@@ -111,7 +111,8 @@ export const versionRouter = createTRPCRouter({
tagName: release.tag_name, tagName: release.tag_name,
name: release.name, name: release.name,
publishedAt: release.published_at, publishedAt: release.published_at,
htmlUrl: release.html_url htmlUrl: release.html_url,
body: release.body
} }
}; };
} catch (error) { } catch (error) {

View File

@@ -519,13 +519,16 @@ export class ScriptDownloaderService {
comparisonPromises.push( comparisonPromises.push(
this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`) this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`)
.then(result => { .then(result => {
if (result.error) {
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
}
if (result.hasDifferences) { if (result.hasDifferences) {
hasDifferences = true; hasDifferences = true;
differences.push(result.filePath); differences.push(result.filePath);
} }
}) })
.catch(() => { .catch((error) => {
// Don't add to differences if there's an error reading files console.error(`[Comparison] Promise error for ${scriptPath}:`, error);
}) })
); );
} }
@@ -541,13 +544,16 @@ export class ScriptDownloaderService {
comparisonPromises.push( comparisonPromises.push(
this.compareSingleFile(script, installScriptPath, installScriptPath) this.compareSingleFile(script, installScriptPath, installScriptPath)
.then(result => { .then(result => {
if (result.error) {
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
}
if (result.hasDifferences) { if (result.hasDifferences) {
hasDifferences = true; hasDifferences = true;
differences.push(result.filePath); differences.push(result.filePath);
} }
}) })
.catch(() => { .catch((error) => {
// Don't add to differences if there's an error reading files console.error(`[Comparison] Promise error for ${installScriptPath}:`, error);
}) })
); );
} }
@@ -567,13 +573,16 @@ export class ScriptDownloaderService {
comparisonPromises.push( comparisonPromises.push(
this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath) this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath)
.then(result => { .then(result => {
if (result.error) {
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
}
if (result.hasDifferences) { if (result.hasDifferences) {
hasDifferences = true; hasDifferences = true;
differences.push(result.filePath); differences.push(result.filePath);
} }
}) })
.catch(() => { .catch((error) => {
// Don't add to differences if there's an error reading files console.error(`[Comparison] Promise error for ${alpineInstallScriptPath}:`, error);
}) })
); );
} catch { } catch {
@@ -584,10 +593,11 @@ export class ScriptDownloaderService {
// Wait for all comparisons to complete // Wait for all comparisons to complete
await Promise.all(comparisonPromises); await Promise.all(comparisonPromises);
console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`);
return { hasDifferences, differences }; return { hasDifferences, differences };
} catch (error) { } catch (error) {
console.error('Error comparing script content:', error); console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error);
return { hasDifferences: false, differences: [] }; return { hasDifferences: false, differences: [], error: error.message };
} }
} }
@@ -597,16 +607,21 @@ export class ScriptDownloaderService {
const repoUrl = this.getRepoUrlForScript(script); const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main'; const branch = process.env.REPO_BRANCH || 'main';
console.log(`[Comparison] Comparing ${filePath} from ${repoUrl} (branch: ${branch})`);
// Read local content // Read local content
const localContent = await readFile(localPath, 'utf-8'); const localContent = await readFile(localPath, 'utf-8');
console.log(`[Comparison] Local file size: ${localContent.length} bytes`);
// Download remote content from the script's repository // Download remote content from the script's repository
const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch); const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch);
console.log(`[Comparison] Remote file size: ${remoteContent.length} bytes`);
// Apply modification only for CT scripts, not for other script types // Apply modification only for CT scripts, not for other script types
let modifiedRemoteContent; let modifiedRemoteContent;
if (remotePath.startsWith('ct/')) { if (remotePath.startsWith('ct/')) {
modifiedRemoteContent = this.modifyScriptContent(remoteContent); modifiedRemoteContent = this.modifyScriptContent(remoteContent);
console.log(`[Comparison] Applied CT script modifications`);
} else { } else {
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
} }
@@ -614,10 +629,17 @@ export class ScriptDownloaderService {
// Compare content // Compare content
const hasDifferences = localContent !== modifiedRemoteContent; const hasDifferences = localContent !== modifiedRemoteContent;
if (hasDifferences) {
console.log(`[Comparison] Differences found in ${filePath}`);
} else {
console.log(`[Comparison] No differences in ${filePath}`);
}
return { hasDifferences, filePath }; return { hasDifferences, filePath };
} catch (error) { } catch (error) {
console.error(`Error comparing file ${filePath}:`, error); console.error(`[Comparison] Error comparing file ${filePath}:`, error.message);
return { hasDifferences: false, filePath }; // Return error information so it can be handled upstream
return { hasDifferences: false, filePath, error: error.message };
} }
} }

View File

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

View File

@@ -851,6 +851,59 @@ rollback() {
exit 1 exit 1
} }
# Check installed Node.js version and upgrade if needed
check_node_version() {
if ! command -v node &>/dev/null; then
log_error "Node.js is not installed"
exit 1
fi
local current major_version
current=$(node -v 2>/dev/null | tr -d 'v')
major_version=${current%%.*}
log "Detected Node.js version: $current"
if (( major_version < 24 )); then
log_warning "Node.js < 24 detected → upgrading to Node.js 24 LTS..."
upgrade_node_to_24
elif (( major_version > 24 )); then
log_warning "Node.js > 24 detected → script tested only up to Node 24"
log "Continuing anyway…"
else
log_success "Node.js 24 already installed"
fi
}
# Upgrade Node.js to version 24
upgrade_node_to_24() {
log "Preparing Node.js 24 upgrade…"
# Remove old nodesource repo if it exists
if [ -f /etc/apt/sources.list.d/nodesource.list ]; then
rm -f /etc/apt/sources.list.d/nodesource.list
fi
# Install NodeSource repo for Node.js 24
curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/node24_setup.sh
if ! bash /tmp/node24_setup.sh > /tmp/node24_setup.log 2>&1; then
log_error "Failed to configure Node.js 24 repository"
tail -20 /tmp/node24_setup.log | while read -r line; do log_error "$line"; done
exit 1
fi
log "Installing Node.js 24…"
if ! apt-get install -y nodejs >> "$LOG_FILE" 2>&1; then
log_error "Failed to install Node.js 24"
exit 1
fi
local new_ver
new_ver=$(node -v 2>/dev/null || true)
log_success "Node.js successfully upgraded to $new_ver"
}
# Main update process # Main update process
main() { main() {
# Check if this is the relocated/detached version first # Check if this is the relocated/detached version first
@@ -914,6 +967,12 @@ main() {
# Stop the application before updating # Stop the application before updating
stop_application stop_application
# Check Node.js version
check_node_version
#Update Node.js to 24
upgrade_node_to_24
# Download and extract release # Download and extract release
local source_dir local source_dir
source_dir=$(download_release "$release_info") source_dir=$(download_release "$release_info")