Compare commits
28 Commits
feat/lxc_b
...
fix/some_s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b52188083a | ||
|
|
53df7201d6 | ||
|
|
65bed15722 | ||
|
|
d3da1038db | ||
|
|
3d45e6d355 | ||
|
|
b01c029b18 | ||
|
|
c77cd33019 | ||
|
|
c8825dddf9 | ||
|
|
cd51945d27 | ||
|
|
1d36f760a7 | ||
|
|
d50aff366c | ||
|
|
2a9921a4e1 | ||
|
|
50f657ba00 | ||
|
|
5d5eba72de | ||
|
|
577b96518e | ||
|
|
c6c27271d6 | ||
|
|
72c0246d8c | ||
|
|
06d4786e0a | ||
|
|
bc31896586 | ||
|
|
213a606fc0 | ||
|
|
3579f2258e | ||
|
|
5b861ade05 | ||
|
|
553eae6ce7 | ||
|
|
c2ca88f033 | ||
|
|
67d44a6a5f | ||
|
|
fe6cca5c63 | ||
|
|
014e5b69e9 | ||
|
|
f37b2cb26f |
2
.github/workflows/node.js.yml
vendored
2
.github/workflows/node.js.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
node-version: [24.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
|
||||
@@ -43,6 +43,10 @@ const config = {
|
||||
'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 }) => {
|
||||
if (dev && !isServer) {
|
||||
config.watchOptions = {
|
||||
@@ -50,12 +54,15 @@ const config = {
|
||||
aggregateTimeout: 300,
|
||||
};
|
||||
}
|
||||
// Handle server-side modules
|
||||
if (isServer) {
|
||||
config.externals = config.externals || [];
|
||||
if (!config.externals.includes('child_process')) {
|
||||
config.externals.push('child_process');
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
// Ignore ESLint errors during build (they can be fixed separately)
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
// Ignore TypeScript errors during build (they can be fixed separately)
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
|
||||
2746
package-lock.json
generated
2746
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@@ -4,11 +4,11 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --webpack",
|
||||
"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:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint": "next lint",
|
||||
@@ -22,7 +22,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.18.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
@@ -43,14 +43,14 @@
|
||||
"cron-validator": "^1.2.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^15.1.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"lucide-react": "^0.554.0",
|
||||
"next": "^16.0.4",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"refractor": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
@@ -69,15 +69,15 @@
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^19.2.4",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-next": "^15.1.6",
|
||||
"@vitest/coverage-v8": "^4.0.13",
|
||||
"@vitest/ui": "^4.0.13",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.4",
|
||||
"jsdom": "^27.2.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
@@ -86,13 +86,16 @@
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.13"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"packageManager": "npm@10.9.3",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"prismjs": "^1.30.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ root_check() {
|
||||
}
|
||||
|
||||
# This function checks the version of Proxmox Virtual Environment (PVE) and exits if the version is not supported.
|
||||
# Supported: Proxmox VE 8.0.x – 8.9.x and 9.0 (NOT 9.1+)
|
||||
# Supported: Proxmox VE 8.0.x – 8.9.x, 9.0 and 9.1
|
||||
pve_check() {
|
||||
local PVE_VER
|
||||
PVE_VER="$(pveversion | awk -F'/' '{print $2}' | awk -F'-' '{print $1}')"
|
||||
@@ -76,12 +76,12 @@ pve_check() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for Proxmox VE 9.x: allow ONLY 9.0
|
||||
# Check for Proxmox VE 9.x: allow 9.0 and 9.1
|
||||
if [[ "$PVE_VER" =~ ^9\.([0-9]+) ]]; then
|
||||
local MINOR="${BASH_REMATCH[1]}"
|
||||
if ((MINOR != 0)); then
|
||||
msg_error "This version of Proxmox VE is not yet supported."
|
||||
msg_error "Supported: Proxmox VE version 9.0"
|
||||
if ((MINOR < 0 || MINOR > 1)); then
|
||||
msg_error "This version of Proxmox VE is not supported."
|
||||
msg_error "Supported: Proxmox VE version 9.0 – 9.1"
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
@@ -89,7 +89,7 @@ pve_check() {
|
||||
|
||||
# All other unsupported versions
|
||||
msg_error "This version of Proxmox VE is not supported."
|
||||
msg_error "Supported versions: Proxmox VE 8.0 – 8.x or 9.0"
|
||||
msg_error "Supported versions: Proxmox VE 8.0 – 8.x or 9.0 – 9.1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1323,9 +1323,9 @@ EOF'
|
||||
msg_ok "Customized LXC Container"
|
||||
|
||||
if [ "$var_os" == "alpine" ]; then
|
||||
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/alpine-install.func")"
|
||||
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/api.func" && echo && cat "$CORE_DIR/alpine-install.func")"
|
||||
else
|
||||
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/install.func")"
|
||||
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/api.func" && echo && cat "$CORE_DIR/install.func")"
|
||||
fi
|
||||
|
||||
FUNCTIONS_FILE="/tmp/functions.sh"
|
||||
|
||||
@@ -392,8 +392,6 @@ cleanup_lxc() {
|
||||
|
||||
# Python pip
|
||||
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
|
||||
if command -v npm &>/dev/null; then $STD npm cache clean --force || true; fi
|
||||
# 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 journalctl &>/dev/null; then
|
||||
$STD journalctl --rotate || true
|
||||
$STD journalctl --vacuum-time=10m || true
|
||||
fi
|
||||
msg_ok "Cleaned"
|
||||
|
||||
58
server.js
58
server.js
@@ -79,14 +79,27 @@ class ScriptExecutionHandler {
|
||||
* @param {import('http').Server} server
|
||||
*/
|
||||
constructor(server) {
|
||||
// Create WebSocketServer without attaching to server
|
||||
// We'll handle upgrades manually to avoid interfering with Next.js HMR
|
||||
this.wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/ws/script-execution'
|
||||
noServer: true
|
||||
});
|
||||
this.activeExecutions = new Map();
|
||||
this.db = getDatabase();
|
||||
this.setupWebSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket upgrade for our endpoint
|
||||
* @param {import('http').IncomingMessage} request
|
||||
* @param {import('stream').Duplex} socket
|
||||
* @param {Buffer} head
|
||||
*/
|
||||
handleUpgrade(request, socket, head) {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.wss.emit('connection', ws, request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Container ID from terminal output
|
||||
@@ -1159,12 +1172,22 @@ app.prepare().then(() => {
|
||||
const parsedUrl = parse(req.url || '', true);
|
||||
const { pathname, query } = parsedUrl;
|
||||
|
||||
if (pathname === '/ws/script-execution') {
|
||||
// Check if this is a WebSocket upgrade request
|
||||
const isWebSocketUpgrade = req.headers.upgrade === 'websocket';
|
||||
|
||||
// Only intercept WebSocket upgrades for /ws/script-execution
|
||||
// Let Next.js handle all other WebSocket upgrades (like HMR) and all HTTP requests
|
||||
if (isWebSocketUpgrade && pathname === '/ws/script-execution') {
|
||||
// WebSocket upgrade will be handled by the WebSocket server
|
||||
// Don't call handle() for this path - let WebSocketServer handle it
|
||||
return;
|
||||
}
|
||||
|
||||
// Let Next.js handle all other requests including HMR
|
||||
// Let Next.js handle all other requests including:
|
||||
// - HTTP requests to /ws/script-execution (non-WebSocket)
|
||||
// - WebSocket upgrades to other paths (like /_next/webpack-hmr)
|
||||
// - All static assets (_next routes)
|
||||
// - All other routes
|
||||
await handle(req, res, parsedUrl);
|
||||
} catch (err) {
|
||||
console.error('Error occurred handling', req.url, err);
|
||||
@@ -1175,6 +1198,33 @@ app.prepare().then(() => {
|
||||
|
||||
// Create WebSocket handlers
|
||||
const scriptHandler = new ScriptExecutionHandler(httpServer);
|
||||
|
||||
// Handle WebSocket upgrades manually to avoid interfering with Next.js HMR
|
||||
// We need to preserve Next.js's upgrade handlers and call them for non-matching paths
|
||||
// Save any existing upgrade listeners (Next.js might have set them up)
|
||||
const existingUpgradeListeners = httpServer.listeners('upgrade').slice();
|
||||
httpServer.removeAllListeners('upgrade');
|
||||
|
||||
// Add our upgrade handler that routes based on path
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
const parsedUrl = parse(request.url || '', true);
|
||||
const { pathname } = parsedUrl;
|
||||
|
||||
if (pathname === '/ws/script-execution') {
|
||||
// Handle our custom WebSocket endpoint
|
||||
scriptHandler.handleUpgrade(request, socket, head);
|
||||
} else {
|
||||
// For all other paths (including Next.js HMR), call existing listeners
|
||||
// This allows Next.js to handle its own WebSocket upgrades
|
||||
for (const listener of existingUpgradeListeners) {
|
||||
try {
|
||||
listener.call(httpServer, request, socket, head);
|
||||
} catch (err) {
|
||||
console.error('Error in upgrade listener:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Note: TerminalHandler removed as it's not being used by the current application
|
||||
|
||||
httpServer
|
||||
|
||||
@@ -187,9 +187,10 @@ export function CategorySidebar({
|
||||
'Miscellaneous': 'box'
|
||||
};
|
||||
|
||||
// Sort categories by count (descending) and then alphabetically
|
||||
// Filter categories to only show those with scripts, then sort by count (descending) and alphabetically
|
||||
const sortedCategories = categories
|
||||
.map(category => [category, categoryCounts[category] ?? 0] as const)
|
||||
.filter(([, count]) => count > 0) // Only show categories with at least one script
|
||||
.sort(([a, countA], [b, countB]) => {
|
||||
if (countB !== countA) return countB - countA;
|
||||
return a.localeCompare(b);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock, GitBranch } from 'lucide-react';
|
||||
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock, GitBranch, Archive } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
|
||||
interface HelpModalProps {
|
||||
@@ -11,7 +11,7 @@ interface HelpModalProps {
|
||||
initialSection?: string;
|
||||
}
|
||||
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system' | 'repositories';
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system' | 'repositories' | 'backups';
|
||||
|
||||
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
|
||||
@@ -30,6 +30,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
|
||||
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
|
||||
{ id: 'lxc-settings' as HelpSection, label: 'LXC Settings', icon: Settings },
|
||||
{ id: 'backups' as HelpSection, label: 'LXC Backups', icon: Archive },
|
||||
{ id: 'update-system' as HelpSection, label: 'Update System', icon: Download },
|
||||
];
|
||||
|
||||
@@ -925,6 +926,144 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'backups':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">LXC Backups</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Create backups of your LXC containers before updates or on-demand. Backups are created using Proxmox VE's built-in backup system and can be stored on any backup-capable storage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg bg-primary/10 border-primary/20">
|
||||
<h4 className="font-medium text-foreground mb-2">Overview</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
The backup feature allows you to create snapshots of your LXC containers before performing updates or at any time. Backups are created using the <code className="bg-muted px-1 rounded">vzdump</code> command via SSH and stored on your configured Proxmox storage.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• <strong>Pre-Update Backups:</strong> Automatically create backups before updating containers</li>
|
||||
<li>• <strong>Standalone Backups:</strong> Create backups on-demand from the Actions menu</li>
|
||||
<li>• <strong>Storage Selection:</strong> Choose from available backup-capable storages</li>
|
||||
<li>• <strong>Real-Time Progress:</strong> View backup progress in the terminal output</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Backup Before Update</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
When updating an LXC container, you can choose to create a backup first:
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Click the "Update" button for an installed script</li>
|
||||
<li>Confirm that you want to update the container</li>
|
||||
<li>Choose whether to create a backup before updating</li>
|
||||
<li>If yes, select a backup-capable storage from the list</li>
|
||||
<li>The backup will be created, then the update will proceed automatically</li>
|
||||
</ol>
|
||||
<div className="mt-3 p-3 bg-info/10 rounded-md">
|
||||
<h5 className="font-medium text-info-foreground mb-2">Backup Failure Handling</h5>
|
||||
<p className="text-xs text-info/80">
|
||||
If a backup fails, you'll be warned but can still choose to proceed with the update. This ensures updates aren't blocked by backup issues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Standalone Backup</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Create a backup at any time without updating:
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Open the Actions dropdown menu for an installed script</li>
|
||||
<li>Click "Backup"</li>
|
||||
<li>Select a backup-capable storage from the list</li>
|
||||
<li>Watch the backup progress in the terminal output</li>
|
||||
</ol>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
<strong>Note:</strong> Standalone backups are only available for SSH-enabled scripts with valid container IDs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Storage Selection</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
The system automatically discovers backup-capable storages from your Proxmox servers:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Automatic Discovery:</strong> Storages are fetched from <code className="bg-muted px-1 rounded">/etc/pve/storage.cfg</code> on each server</li>
|
||||
<li>• <strong>Backup-Capable Only:</strong> Only storages with "backup" in their content are shown</li>
|
||||
<li>• <strong>Cached Results:</strong> Storage lists are cached for 1 hour to improve performance</li>
|
||||
<li>• <strong>Manual Refresh:</strong> Use the "Fetch Storages" button to refresh the list if needed</li>
|
||||
</ul>
|
||||
<div className="mt-3 p-3 bg-muted/30 rounded-md">
|
||||
<h5 className="font-medium text-foreground mb-1">Storage Types</h5>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>• <strong>Local:</strong> Backups stored on the Proxmox host</li>
|
||||
<li>• <strong>Storage:</strong> Network-attached storage (NFS, CIFS, etc.)</li>
|
||||
<li>• <strong>PBS:</strong> Proxmox Backup Server storage</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Viewing Available Storages</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
You can view all storages for a server, including which ones support backups:
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Go to the Server Settings section</li>
|
||||
<li>Find the server you want to check</li>
|
||||
<li>Click the "View Storages" button (database icon)</li>
|
||||
<li>See all storages with backup-capable ones highlighted</li>
|
||||
</ol>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
This helps you identify which storages are available for backups before starting a backup operation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Backup Process</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
When a backup is initiated, the following happens:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>SSH Connection:</strong> Connects to the Proxmox server via SSH</li>
|
||||
<li>• <strong>Command Execution:</strong> Runs <code className="bg-muted px-1 rounded">vzdump <CTID> --storage <STORAGE> --mode snapshot</code></li>
|
||||
<li>• <strong>Real-Time Output:</strong> Backup progress is streamed to the terminal</li>
|
||||
<li>• <strong>Completion:</strong> Backup completes and shows success/failure status</li>
|
||||
<li>• <strong>Sequential Execution:</strong> If part of update flow, update proceeds after backup completes</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-warning/10 border-warning/20">
|
||||
<h4 className="font-medium text-warning-foreground mb-2">⚠️ Important Notes</h4>
|
||||
<ul className="text-sm text-warning/80 space-y-2">
|
||||
<li>• <strong>Storage Requirements:</strong> Ensure you have sufficient storage space for backups</li>
|
||||
<li>• <strong>Backup Duration:</strong> Backup time depends on container size and storage speed</li>
|
||||
<li>• <strong>Snapshot Mode:</strong> Backups use snapshot mode, which requires sufficient disk space</li>
|
||||
<li>• <strong>SSH Access:</strong> Backups require valid SSH credentials configured for the server</li>
|
||||
<li>• <strong>Container State:</strong> Containers can be running or stopped during backup</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Backup Storage Cache</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Storage information is cached to improve performance:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• <strong>Cache Duration:</strong> Storage lists are cached for 1 hour</li>
|
||||
<li>• <strong>Automatic Refresh:</strong> Cache expires and refreshes automatically</li>
|
||||
<li>• <strong>Manual Refresh:</strong> Use "Fetch Storages" button to force refresh</li>
|
||||
<li>• <strong>Per-Server Cache:</strong> Each server has its own cached storage list</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
@@ -9,6 +9,8 @@ export function ResyncButton() {
|
||||
const [isResyncing, setIsResyncing] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const hasReloadedRef = useRef<boolean>(false);
|
||||
const isUserInitiatedRef = useRef<boolean>(false);
|
||||
|
||||
const resyncMutation = api.scripts.resyncScripts.useMutation({
|
||||
onSuccess: (data) => {
|
||||
@@ -16,24 +18,38 @@ export function ResyncButton() {
|
||||
setLastSync(new Date());
|
||||
if (data.success) {
|
||||
setSyncMessage(data.message ?? 'Scripts synced successfully');
|
||||
// Reload the page after successful sync
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000); // Wait 2 seconds to show the success message
|
||||
// Only reload if this was triggered by user action
|
||||
if (isUserInitiatedRef.current && !hasReloadedRef.current) {
|
||||
hasReloadedRef.current = true;
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000); // Wait 2 seconds to show the success message
|
||||
} else {
|
||||
// Reset flag if reload didn't happen
|
||||
isUserInitiatedRef.current = false;
|
||||
}
|
||||
} else {
|
||||
setSyncMessage(data.error ?? 'Failed to sync scripts');
|
||||
// Clear message after 3 seconds for errors
|
||||
setTimeout(() => setSyncMessage(null), 3000);
|
||||
isUserInitiatedRef.current = false;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsResyncing(false);
|
||||
setSyncMessage(`Error: ${error.message}`);
|
||||
setTimeout(() => setSyncMessage(null), 3000);
|
||||
isUserInitiatedRef.current = false;
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
setSyncMessage(null);
|
||||
resyncMutation.mutate();
|
||||
|
||||
@@ -61,7 +61,11 @@ export function ScriptDetailModal({
|
||||
isLoading: comparisonLoading,
|
||||
} = api.scripts.compareScriptContent.useQuery(
|
||||
{ slug: script?.slug ?? "" },
|
||||
{ enabled: !!script && isOpen },
|
||||
{
|
||||
enabled: !!script && isOpen,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Load script mutation
|
||||
@@ -547,19 +551,60 @@ export function ScriptDetailModal({
|
||||
</div>
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists ||
|
||||
scriptFilesData.installExists) &&
|
||||
comparisonData?.success &&
|
||||
!comparisonLoading && (
|
||||
scriptFilesData.installExists) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
|
||||
></div>
|
||||
<span>
|
||||
Status:{" "}
|
||||
{comparisonData.hasDifferences
|
||||
? "Update available"
|
||||
: "Up to date"}
|
||||
</span>
|
||||
{comparisonData?.success ? (
|
||||
<>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
|
||||
></div>
|
||||
<span>
|
||||
Status:{" "}
|
||||
{comparisonData.hasDifferences
|
||||
? "Update available"
|
||||
: "Up to date"}
|
||||
</span>
|
||||
</>
|
||||
) : comparisonLoading ? (
|
||||
<>
|
||||
<div className="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>
|
||||
@@ -837,7 +882,7 @@ export function ScriptDetailModal({
|
||||
<TextViewer
|
||||
scriptName={
|
||||
script.install_methods
|
||||
?.find((method) => method.script?.startsWith("ct/"))
|
||||
?.find((method) => method.script && (method.script.startsWith("ct/") || method.script.startsWith("vm/") || method.script.startsWith("tools/")))
|
||||
?.script?.split("/")
|
||||
.pop() ?? `${script.slug}.sh`
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ interface TextViewerProps {
|
||||
}
|
||||
|
||||
interface ScriptContent {
|
||||
ctScript?: string;
|
||||
mainScript?: string;
|
||||
installScript?: string;
|
||||
alpineCtScript?: string;
|
||||
alpineMainScript?: string;
|
||||
alpineInstallScript?: string;
|
||||
}
|
||||
|
||||
@@ -24,18 +24,27 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
|
||||
const [activeTab, setActiveTab] = useState<'main' | 'install'>('main');
|
||||
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
|
||||
|
||||
// Extract slug from script name (remove .sh extension)
|
||||
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
|
||||
|
||||
// Check if alpine variant exists
|
||||
const hasAlpineVariant = script?.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
);
|
||||
// Get default and alpine install methods
|
||||
const defaultMethod = script?.install_methods?.find(method => method.type === 'default');
|
||||
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 alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
|
||||
|
||||
@@ -44,116 +53,72 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Build fetch requests for default version
|
||||
// Build fetch requests based on actual script paths from install_methods
|
||||
const requests: Promise<Response>[] = [];
|
||||
|
||||
// Default CT script
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
|
||||
// Tools, VM, VW scripts
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
|
||||
// Default install script
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
|
||||
);
|
||||
|
||||
// Alpine versions if variant exists
|
||||
if (hasAlpineVariant) {
|
||||
const requestTypes: Array<'default-main' | 'default-install' | 'alpine-main' | 'alpine-install'> = [];
|
||||
|
||||
// Default main script (ct/, vm/, tools/, etc.)
|
||||
if (defaultScriptPath) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`)
|
||||
);
|
||||
requestTypes.push('default-main');
|
||||
}
|
||||
|
||||
// Default install script (only for ct/ scripts)
|
||||
if (hasInstallScript && defaultScriptPath?.startsWith('ct/')) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
|
||||
);
|
||||
requestTypes.push('default-install');
|
||||
}
|
||||
|
||||
// Alpine main script
|
||||
if (hasAlpineVariant && alpineScriptPath) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`)
|
||||
);
|
||||
requestTypes.push('alpine-main');
|
||||
}
|
||||
|
||||
// Alpine install script (only for ct/ scripts)
|
||||
if (hasAlpineVariant && hasInstallScript && alpineScriptPath?.startsWith('ct/')) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
|
||||
);
|
||||
requestTypes.push('alpine-install');
|
||||
}
|
||||
|
||||
const responses = await Promise.allSettled(requests);
|
||||
|
||||
const content: ScriptContent = {};
|
||||
let responseIndex = 0;
|
||||
|
||||
// Default CT script
|
||||
const ctResponse = responses[responseIndex];
|
||||
if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
|
||||
const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (ctData.result?.data?.json?.success) {
|
||||
content.ctScript = ctData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// Tools script
|
||||
const toolsResponse = responses[responseIndex];
|
||||
if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) {
|
||||
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (toolsData.result?.data?.json?.success) {
|
||||
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// VM script
|
||||
const vmResponse = responses[responseIndex];
|
||||
if (vmResponse?.status === 'fulfilled' && vmResponse.value.ok) {
|
||||
const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vmData.result?.data?.json?.success) {
|
||||
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// VW script
|
||||
const vwResponse = responses[responseIndex];
|
||||
if (vwResponse?.status === 'fulfilled' && vwResponse.value.ok) {
|
||||
const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vwData.result?.data?.json?.success) {
|
||||
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// Default install script
|
||||
const installResponse = responses[responseIndex];
|
||||
if (installResponse?.status === 'fulfilled' && installResponse.value.ok) {
|
||||
const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (installData.result?.data?.json?.success) {
|
||||
content.installScript = installData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
responseIndex++;
|
||||
// Alpine CT script
|
||||
if (hasAlpineVariant) {
|
||||
const alpineCtResponse = responses[responseIndex];
|
||||
if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) {
|
||||
const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (alpineCtData.result?.data?.json?.success) {
|
||||
content.alpineCtScript = alpineCtData.result.data.json.content;
|
||||
// Process responses based on their types
|
||||
await Promise.all(responses.map(async (response, index) => {
|
||||
if (response.status === 'fulfilled' && response.value.ok) {
|
||||
try {
|
||||
const data = await response.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
const type = requestTypes[index];
|
||||
if (data.result?.data?.json?.success && data.result.data.json.content) {
|
||||
switch (type) {
|
||||
case 'default-main':
|
||||
content.mainScript = data.result.data.json.content;
|
||||
break;
|
||||
case 'default-install':
|
||||
content.installScript = data.result.data.json.content;
|
||||
break;
|
||||
case 'alpine-main':
|
||||
content.alpineMainScript = data.result.data.json.content;
|
||||
break;
|
||||
case 'alpine-install':
|
||||
content.alpineInstallScript = data.result.data.json.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
responseIndex++;
|
||||
}
|
||||
|
||||
// Alpine install script
|
||||
if (hasAlpineVariant) {
|
||||
const alpineInstallResponse = responses[responseIndex];
|
||||
if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) {
|
||||
const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (alpineInstallData.result?.data?.json?.success) {
|
||||
content.alpineInstallScript = alpineInstallData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
setScriptContent(content);
|
||||
} catch (err) {
|
||||
@@ -161,7 +126,7 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
|
||||
}, [defaultScriptPath, alpineScriptPath, slug, hasAlpineVariant, hasInstallScript]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && scriptName) {
|
||||
@@ -207,23 +172,25 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
|
||||
(selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
|
||||
{((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) ||
|
||||
(selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('ct')}
|
||||
variant={activeTab === 'main' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('main')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
CT Script
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'install' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('install')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Install Script
|
||||
Script
|
||||
</Button>
|
||||
{hasInstallScript && (
|
||||
<Button
|
||||
variant={activeTab === 'install' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('install')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Install Script
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -249,8 +216,8 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === 'ct' && (
|
||||
selectedVersion === 'default' && scriptContent.ctScript ? (
|
||||
{activeTab === 'main' && (
|
||||
selectedVersion === 'default' && scriptContent.mainScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
@@ -264,9 +231,9 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.ctScript}
|
||||
{scriptContent.mainScript}
|
||||
</SyntaxHighlighter>
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineMainScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
@@ -280,12 +247,12 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.alpineCtScript}
|
||||
{scriptContent.alpineMainScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
|
||||
{selectedVersion === 'default' ? 'Default script not found' : 'Alpine script not found'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
175
src/app/_components/UpdateConfirmationModal.tsx
Normal file
175
src/app/_components/UpdateConfirmationModal.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'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 { Button } from "./ui/button";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
import { UpdateConfirmationModal } from "./UpdateConfirmationModal";
|
||||
|
||||
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface VersionDisplayProps {
|
||||
onOpenReleaseNotes?: () => void;
|
||||
@@ -85,8 +86,12 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
|
||||
const [shouldSubscribe, setShouldSubscribe] = useState(false);
|
||||
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
||||
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
|
||||
const lastLogTimeRef = useRef<number>(Date.now());
|
||||
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({
|
||||
onSuccess: (result) => {
|
||||
@@ -98,11 +103,13 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
setUpdateLogs(['Update started...']);
|
||||
} else {
|
||||
setIsUpdating(false);
|
||||
setShouldSubscribe(false); // Reset subscription on failure
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setUpdateResult({ success: false, message: error.message });
|
||||
setIsUpdating(false);
|
||||
setShouldSubscribe(false); // Reset subscription on error
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,63 +120,49 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
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
|
||||
const startReconnectAttempts = () => {
|
||||
if (reconnectIntervalRef.current) return;
|
||||
// Memoized with useCallback to prevent recreation on every render
|
||||
// Only depends on refs to avoid stale closures
|
||||
const startReconnectAttempts = useCallback(() => {
|
||||
// CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts
|
||||
// Only start if we're actually updating and haven't already started
|
||||
// Double-check isUpdating state to prevent false triggers from stale data
|
||||
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
|
||||
|
||||
reconnectIntervalRef.current = setInterval(() => {
|
||||
void (async () => {
|
||||
// Guard: Only proceed if we're still updating and in network error state
|
||||
// Check refs directly to avoid stale closures
|
||||
if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current) {
|
||||
// Clear interval if we're no longer updating
|
||||
if (!isUpdatingRef.current && reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to fetch the root path to check if server is back
|
||||
const response = await fetch('/', { method: 'HEAD' });
|
||||
if (response.ok || response.status === 200) {
|
||||
// Double-check we're still updating 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...']);
|
||||
|
||||
// Clear interval and reload
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -181,18 +174,101 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
}
|
||||
})();
|
||||
}, 2000);
|
||||
};
|
||||
}, []); // Empty deps - only uses refs which are stable
|
||||
|
||||
// Cleanup reconnect interval on unmount
|
||||
// Update logs when data changes
|
||||
useEffect(() => {
|
||||
// CRITICAL: Only process update logs if we're actually updating
|
||||
// This prevents stale isComplete data from triggering reloads when not updating
|
||||
if (!isUpdating) {
|
||||
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 () => {
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [isUpdating]);
|
||||
|
||||
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);
|
||||
setUpdateResult(null);
|
||||
setIsNetworkError(false);
|
||||
@@ -200,6 +276,12 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
setShouldSubscribe(false);
|
||||
setUpdateStartTime(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();
|
||||
};
|
||||
|
||||
@@ -233,6 +315,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
{/* Loading overlay */}
|
||||
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
||||
|
||||
{/* Update Confirmation Modal */}
|
||||
{versionStatus?.releaseInfo && (
|
||||
<UpdateConfirmationModal
|
||||
isOpen={showUpdateConfirmation}
|
||||
onClose={() => setShowUpdateConfirmation(false)}
|
||||
onConfirm={handleConfirmUpdate}
|
||||
releaseInfo={versionStatus.releaseInfo}
|
||||
currentVersion={versionStatus.currentVersion}
|
||||
latestVersion={versionStatus.latestVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
||||
<Badge
|
||||
variant={isUpToDate ? "default" : "secondary"}
|
||||
|
||||
@@ -95,6 +95,13 @@ export default function Home() {
|
||||
downloaded: (() => {
|
||||
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
||||
|
||||
// Helper to normalize identifiers for robust matching
|
||||
const normalizeId = (s?: string): string => (s ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\.(sh|bash|py|js|ts)$/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
// First deduplicate GitHub scripts using Map by slug
|
||||
const scriptMap = new Map<string, any>();
|
||||
|
||||
@@ -110,13 +117,36 @@ export default function Home() {
|
||||
const localScripts = localScriptsData.scripts ?? [];
|
||||
|
||||
// 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 => {
|
||||
if (!script?.name) return false;
|
||||
|
||||
// Check if there's a corresponding local script
|
||||
return localScripts.some(local => {
|
||||
if (!local?.name) return false;
|
||||
const localName = local.name.replace(/\.sh$/, '');
|
||||
return localName.toLowerCase() === script.name.toLowerCase() ||
|
||||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
|
||||
|
||||
// Primary: Exact slug-to-slug matching (most reliable)
|
||||
if (local.slug && script.slug) {
|
||||
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
|
||||
if (normalizeId(local.slug) === 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;
|
||||
})(),
|
||||
|
||||
@@ -111,7 +111,8 @@ export const versionRouter = createTRPCRouter({
|
||||
tagName: release.tag_name,
|
||||
name: release.name,
|
||||
publishedAt: release.published_at,
|
||||
htmlUrl: release.html_url
|
||||
htmlUrl: release.html_url,
|
||||
body: release.body
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -519,13 +519,16 @@ export class ScriptDownloaderService {
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`)
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
|
||||
}
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
.catch((error) => {
|
||||
console.error(`[Comparison] Promise error for ${scriptPath}:`, error);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -541,13 +544,16 @@ export class ScriptDownloaderService {
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(script, installScriptPath, installScriptPath)
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
|
||||
}
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
.catch((error) => {
|
||||
console.error(`[Comparison] Promise error for ${installScriptPath}:`, error);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -567,13 +573,16 @@ export class ScriptDownloaderService {
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath)
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
|
||||
}
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
.catch((error) => {
|
||||
console.error(`[Comparison] Promise error for ${alpineInstallScriptPath}:`, error);
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
@@ -584,10 +593,11 @@ export class ScriptDownloaderService {
|
||||
// Wait for all comparisons to complete
|
||||
await Promise.all(comparisonPromises);
|
||||
|
||||
console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`);
|
||||
return { hasDifferences, differences };
|
||||
} catch (error) {
|
||||
console.error('Error comparing script content:', error);
|
||||
return { hasDifferences: false, differences: [] };
|
||||
console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error);
|
||||
return { hasDifferences: false, differences: [], error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,16 +607,21 @@ export class ScriptDownloaderService {
|
||||
const repoUrl = this.getRepoUrlForScript(script);
|
||||
const branch = process.env.REPO_BRANCH || 'main';
|
||||
|
||||
console.log(`[Comparison] Comparing ${filePath} from ${repoUrl} (branch: ${branch})`);
|
||||
|
||||
// Read local content
|
||||
const localContent = await readFile(localPath, 'utf-8');
|
||||
console.log(`[Comparison] Local file size: ${localContent.length} bytes`);
|
||||
|
||||
// Download remote content from the script's repository
|
||||
const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch);
|
||||
console.log(`[Comparison] Remote file size: ${remoteContent.length} bytes`);
|
||||
|
||||
// Apply modification only for CT scripts, not for other script types
|
||||
let modifiedRemoteContent;
|
||||
if (remotePath.startsWith('ct/')) {
|
||||
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
|
||||
console.log(`[Comparison] Applied CT script modifications`);
|
||||
} else {
|
||||
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
|
||||
}
|
||||
@@ -614,10 +629,17 @@ export class ScriptDownloaderService {
|
||||
// Compare content
|
||||
const hasDifferences = localContent !== modifiedRemoteContent;
|
||||
|
||||
if (hasDifferences) {
|
||||
console.log(`[Comparison] Differences found in ${filePath}`);
|
||||
} else {
|
||||
console.log(`[Comparison] No differences in ${filePath}`);
|
||||
}
|
||||
|
||||
return { hasDifferences, filePath };
|
||||
} catch (error) {
|
||||
console.error(`Error comparing file ${filePath}:`, error);
|
||||
return { hasDifferences: false, filePath };
|
||||
console.error(`[Comparison] Error comparing file ${filePath}:`, error.message);
|
||||
// Return error information so it can be handled upstream
|
||||
return { hasDifferences: false, filePath, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
Reference in New Issue
Block a user