Compare commits
13 Commits
update-ver
...
fix/vm_det
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5274737ab8 | ||
|
|
f9af7536d0 | ||
|
|
0d39a9bbd0 | ||
|
|
66f8a84260 | ||
|
|
2a9921a4e1 | ||
|
|
50f657ba00 | ||
|
|
5d5eba72de | ||
|
|
577b96518e | ||
|
|
c6c27271d6 | ||
|
|
72c0246d8c | ||
|
|
06d4786e0a | ||
|
|
bc31896586 | ||
|
|
213a606fc0 |
2
.github/workflows/node.js.yml
vendored
2
.github/workflows/node.js.yml
vendored
@@ -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:
|
||||||
|
|||||||
@@ -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
2694
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
58
server.js
58
server.js
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
176
src/app/_components/UpdateConfirmationModal.tsx
Normal file
176
src/app/_components/UpdateConfirmationModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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;
|
||||||
})(),
|
})(),
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
59
update.sh
59
update.sh
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user