PoC
This commit is contained in:
23
.env.example
23
.env.example
@@ -1,14 +1,15 @@
|
||||
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||
# when you add new variables to `.env`.
|
||||
# Database
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/pve_scripts_local"
|
||||
|
||||
# This file will be committed to version control, so make sure not to have any
|
||||
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||
# ".env" and populate it with your secrets.
|
||||
# Repository Configuration
|
||||
REPO_URL="https://github.com/your-org/pve-scripts.git"
|
||||
REPO_BRANCH="main"
|
||||
SCRIPTS_DIRECTORY="scripts"
|
||||
ALLOWED_SCRIPT_EXTENSIONS=".sh,.py,.js,.ts,.bash"
|
||||
|
||||
# When adding additional environment variables, the schema in "/src/env.js"
|
||||
# should be updated accordingly.
|
||||
# Security
|
||||
MAX_SCRIPT_EXECUTION_TIME="300000"
|
||||
ALLOWED_SCRIPT_PATHS="scripts/"
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/pve-scripts-local"
|
||||
# WebSocket Configuration
|
||||
WEBSOCKET_PORT="3001"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
.cursorignore
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
67
package-lock.json
generated
67
package-lock.json
generated
@@ -15,11 +15,14 @@
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"next": "^15.2.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"simple-git": "^3.28.0",
|
||||
"superjson": "^2.2.1",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -759,6 +762,21 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kwsites/file-exists": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@kwsites/promise-deferred": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
|
||||
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@@ -1506,7 +1524,6 @@
|
||||
"version": "20.19.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.13.tgz",
|
||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -1532,6 +1549,15 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz",
|
||||
@@ -2745,7 +2771,6 @@
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -5012,7 +5037,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -6128,6 +6152,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-git": {
|
||||
"version": "3.28.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz",
|
||||
"integrity": "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kwsites/file-exists": "^1.1.1",
|
||||
"@kwsites/promise-deferred": "^1.1.1",
|
||||
"debug": "^4.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/steveukx/git-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
|
||||
@@ -6655,7 +6694,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
@@ -6818,6 +6856,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
|
||||
@@ -10,14 +10,15 @@
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "node server.js",
|
||||
"dev:next": "next dev --turbo",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"postinstall": "prisma generate",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"start": "node server.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -27,11 +28,14 @@
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"next": "^15.2.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"simple-git": "^3.28.0",
|
||||
"superjson": "^2.2.1",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
46
scripts/demo.sh
Normal file
46
scripts/demo.sh
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Demo script for PVE Scripts Local Management
|
||||
# This script demonstrates live output streaming
|
||||
|
||||
echo "🚀 Starting PVE Script Demo..."
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
echo "📋 System Information:"
|
||||
echo " - Hostname: $(hostname)"
|
||||
echo " - User: $(whoami)"
|
||||
echo " - Date: $(date)"
|
||||
echo " - Uptime: $(uptime)"
|
||||
echo ""
|
||||
|
||||
echo "🔧 Simulating Proxmox operations..."
|
||||
echo " - Checking Proxmox API connection..."
|
||||
sleep 2
|
||||
echo " ✅ API connection successful"
|
||||
echo ""
|
||||
|
||||
echo " - Listing VMs..."
|
||||
sleep 1
|
||||
echo " 📦 VM 100: Ubuntu Server 22.04 (running)"
|
||||
echo " 📦 VM 101: Windows Server 2022 (stopped)"
|
||||
echo " 📦 VM 102: Debian 12 (running)"
|
||||
echo ""
|
||||
|
||||
echo " - Checking storage..."
|
||||
sleep 1
|
||||
echo " 💾 Local storage: 500GB (200GB used)"
|
||||
echo " 💾 NFS storage: 2TB (800GB used)"
|
||||
echo ""
|
||||
|
||||
echo " - Checking cluster status..."
|
||||
sleep 1
|
||||
echo " 🏗️ Node: pve-01 (online)"
|
||||
echo " 🏗️ Node: pve-02 (online)"
|
||||
echo " 🏗️ Node: pve-03 (maintenance)"
|
||||
echo ""
|
||||
|
||||
echo "🎯 Demo completed successfully!"
|
||||
echo "================================"
|
||||
echo "This script ran for demonstration purposes."
|
||||
echo "In a real scenario, this would perform actual Proxmox operations."
|
||||
244
server.js
Normal file
244
server.js
Normal file
@@ -0,0 +1,244 @@
|
||||
import { createServer } from 'http';
|
||||
import { parse } from 'url';
|
||||
import next from 'next';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { spawn } from 'child_process';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const hostname = 'localhost';
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// when using middleware `hostname` and `port` must be provided below
|
||||
const app = next({ dev, hostname, port });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
// WebSocket handler for script execution
|
||||
class ScriptExecutionHandler {
|
||||
constructor(server) {
|
||||
this.wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/ws/script-execution'
|
||||
});
|
||||
this.activeExecutions = new Map();
|
||||
this.setupWebSocket();
|
||||
}
|
||||
|
||||
setupWebSocket() {
|
||||
this.wss.on('connection', (ws, request) => {
|
||||
console.log('New WebSocket connection for script execution');
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleMessage(ws, message);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Invalid message format',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket connection closed');
|
||||
this.cleanupActiveExecutions(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.cleanupActiveExecutions(ws);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleMessage(ws, message) {
|
||||
const { action, scriptPath, executionId } = message;
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
if (scriptPath && executionId) {
|
||||
await this.startScriptExecution(ws, scriptPath, executionId);
|
||||
} else {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Missing scriptPath or executionId',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
if (executionId) {
|
||||
this.stopScriptExecution(executionId);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Unknown action',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async startScriptExecution(ws, scriptPath, executionId) {
|
||||
try {
|
||||
// Basic validation
|
||||
const scriptsDir = join(process.cwd(), 'scripts');
|
||||
const resolvedPath = resolve(scriptPath);
|
||||
|
||||
if (!resolvedPath.startsWith(resolve(scriptsDir))) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Script path is not within the allowed scripts directory',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if execution is already running
|
||||
if (this.activeExecutions.has(executionId)) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Script execution already running',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start script execution
|
||||
const process = spawn('bash', [scriptPath], {
|
||||
cwd: scriptsDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: true
|
||||
});
|
||||
|
||||
// Store the execution
|
||||
this.activeExecutions.set(executionId, { process, ws });
|
||||
|
||||
// Send start message
|
||||
this.sendMessage(ws, {
|
||||
type: 'start',
|
||||
data: `Starting execution of ${scriptPath}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Handle stdout
|
||||
process.stdout?.on('data', (data) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: data.toString(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
process.stderr?.on('data', (data) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: data.toString(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
process.on('exit', (code, signal) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'end',
|
||||
data: `Script execution finished with code: ${code}, signal: ${signal}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Clean up
|
||||
this.activeExecutions.delete(executionId);
|
||||
});
|
||||
|
||||
// Handle process error
|
||||
process.on('error', (error) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `Process error: ${error.message}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Clean up
|
||||
this.activeExecutions.delete(executionId);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `Failed to start script: ${error.message}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stopScriptExecution(executionId) {
|
||||
const execution = this.activeExecutions.get(executionId);
|
||||
if (execution) {
|
||||
execution.process.kill('SIGTERM');
|
||||
this.activeExecutions.delete(executionId);
|
||||
|
||||
this.sendMessage(execution.ws, {
|
||||
type: 'end',
|
||||
data: 'Script execution stopped by user',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(ws, message) {
|
||||
if (ws.readyState === 1) { // WebSocket.OPEN
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
cleanupActiveExecutions(ws) {
|
||||
for (const [executionId, execution] of this.activeExecutions.entries()) {
|
||||
if (execution.ws === ws) {
|
||||
execution.process.kill('SIGTERM');
|
||||
this.activeExecutions.delete(executionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.prepare().then(() => {
|
||||
const httpServer = createServer(async (req, res) => {
|
||||
try {
|
||||
// Be sure to pass `true` as the second argument to `url.parse`.
|
||||
// This tells it to parse the query portion of the URL.
|
||||
const parsedUrl = parse(req.url, true);
|
||||
const { pathname, query } = parsedUrl;
|
||||
|
||||
if (pathname === '/ws/script-execution') {
|
||||
// WebSocket upgrade will be handled by the WebSocket server
|
||||
return;
|
||||
}
|
||||
|
||||
await handle(req, res, parsedUrl);
|
||||
} catch (err) {
|
||||
console.error('Error occurred handling', req.url, err);
|
||||
res.statusCode = 500;
|
||||
res.end('internal server error');
|
||||
}
|
||||
});
|
||||
|
||||
// Create WebSocket handler
|
||||
const scriptHandler = new ScriptExecutionHandler(httpServer);
|
||||
|
||||
httpServer
|
||||
.once('error', (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.listen(port, () => {
|
||||
console.log(`> Ready on http://${hostname}:${port}`);
|
||||
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
|
||||
});
|
||||
});
|
||||
117
src/app/_components/RepoStatus.tsx
Normal file
117
src/app/_components/RepoStatus.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function RepoStatus() {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const { data: repoStatus, isLoading: statusLoading, refetch: refetchStatus } = api.scripts.getRepoStatus.useQuery();
|
||||
const updateRepoMutation = api.scripts.updateRepo.useMutation({
|
||||
onSuccess: () => {
|
||||
setIsUpdating(false);
|
||||
refetchStatus();
|
||||
},
|
||||
onError: () => {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setIsUpdating(true);
|
||||
updateRepoMutation.mutate();
|
||||
};
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="text-gray-600">Loading repository status...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!repoStatus) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
|
||||
<div className="text-red-600">Failed to load repository status</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!repoStatus.isRepo) return 'text-gray-500';
|
||||
if (repoStatus.isBehind) return 'text-yellow-600';
|
||||
return 'text-green-600';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!repoStatus.isRepo) return 'No repository found';
|
||||
if (repoStatus.isBehind) return 'Behind remote';
|
||||
return 'Up to date';
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (!repoStatus.isRepo) return '❓';
|
||||
if (repoStatus.isBehind) return '⚠️';
|
||||
return '✅';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-2xl">{getStatusIcon()}</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">Repository Status</h3>
|
||||
<div className={`text-sm font-medium ${getStatusColor()}`}>
|
||||
{getStatusText()}
|
||||
</div>
|
||||
{repoStatus.isRepo && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<p>Branch: {repoStatus.branch || 'Unknown'}</p>
|
||||
{repoStatus.lastCommit && (
|
||||
<p>Last commit: {repoStatus.lastCommit.substring(0, 8)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => refetchStatus()}
|
||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
disabled={isUpdating || !repoStatus.isRepo}
|
||||
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
|
||||
isUpdating || !repoStatus.isRepo
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{isUpdating ? '⏳ Updating...' : '⬇️ Update Repo'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateRepoMutation.isSuccess && (
|
||||
<div className="mt-3 p-2 bg-green-50 border border-green-200 rounded text-sm text-green-700">
|
||||
✅ {updateRepoMutation.data?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateRepoMutation.isError && (
|
||||
<div className="mt-3 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
❌ {updateRepoMutation.error?.message || 'Failed to update repository'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/app/_components/ScriptsList.tsx
Normal file
138
src/app/_components/ScriptsList.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { Terminal } from './Terminal';
|
||||
|
||||
interface ScriptsListProps {
|
||||
onRunScript: (scriptPath: string, scriptName: string) => void;
|
||||
}
|
||||
|
||||
export function ScriptsList({ onRunScript }: ScriptsListProps) {
|
||||
const { data, isLoading, error, refetch } = api.scripts.getScripts.useQuery();
|
||||
const [selectedScript, setSelectedScript] = useState<string | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-lg text-gray-600">Loading scripts...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-lg text-red-600">
|
||||
Error loading scripts: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.scripts || data.scripts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-lg text-gray-600">No scripts found in the scripts directory</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return new Date(date).toLocaleString();
|
||||
};
|
||||
|
||||
const getFileIcon = (extension: string): string => {
|
||||
switch (extension) {
|
||||
case '.sh':
|
||||
case '.bash':
|
||||
return '🐚';
|
||||
case '.py':
|
||||
return '🐍';
|
||||
case '.js':
|
||||
return '📜';
|
||||
case '.ts':
|
||||
return '🔷';
|
||||
default:
|
||||
return '📄';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">Available Scripts</h2>
|
||||
<p className="text-gray-600">
|
||||
Found {data.scripts.length} script(s) in {data.directoryInfo.path}
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<p>Allowed extensions: {data.directoryInfo.allowedExtensions.join(', ')}</p>
|
||||
<p>Max execution time: {Math.round(data.directoryInfo.maxExecutionTime / 1000)}s</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{data.scripts.map((script) => (
|
||||
<div
|
||||
key={script.path}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-2xl">{getFileIcon(script.extension)}</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">{script.name}</h3>
|
||||
<div className="text-sm text-gray-500 space-y-1">
|
||||
<p>Size: {formatFileSize(script.size)}</p>
|
||||
<p>Modified: {formatDate(script.lastModified)}</p>
|
||||
<p>Extension: {script.extension}</p>
|
||||
<p className={`font-medium ${script.executable ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{script.executable ? '✅ Executable' : '❌ Not executable'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setSelectedScript(script.path)}
|
||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRunScript(script.path, script.name)}
|
||||
disabled={!script.executable}
|
||||
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
|
||||
script.executable
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{script.executable ? '▶️ Run' : '🚫 Cannot Run'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedScript && (
|
||||
<div className="mt-6">
|
||||
<Terminal
|
||||
scriptPath={selectedScript}
|
||||
onClose={() => setSelectedScript(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/app/_components/Terminal.tsx
Normal file
206
src/app/_components/Terminal.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
|
||||
interface TerminalProps {
|
||||
scriptPath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface TerminalMessage {
|
||||
type: 'start' | 'output' | 'error' | 'end';
|
||||
data: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function Terminal({ scriptPath, onClose }: TerminalProps) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [output, setOutput] = useState<string[]>([]);
|
||||
const [executionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scriptName = scriptPath.split('/').pop() || scriptPath.split('\\').pop() || 'Unknown Script';
|
||||
|
||||
useEffect(() => {
|
||||
// Connect to WebSocket
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/script-execution`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: TerminalMessage = JSON.parse(event.data);
|
||||
handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setIsConnected(false);
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMessage = (message: TerminalMessage) => {
|
||||
const timestamp = new Date(message.timestamp).toLocaleTimeString();
|
||||
const prefix = `[${timestamp}] `;
|
||||
|
||||
switch (message.type) {
|
||||
case 'start':
|
||||
setOutput(prev => [...prev, `${prefix}🚀 ${message.data}`]);
|
||||
setIsRunning(true);
|
||||
break;
|
||||
case 'output':
|
||||
setOutput(prev => [...prev, message.data]);
|
||||
break;
|
||||
case 'error':
|
||||
setOutput(prev => [...prev, `${prefix}❌ ${message.data}`]);
|
||||
break;
|
||||
case 'end':
|
||||
setOutput(prev => [...prev, `${prefix}✅ ${message.data}`]);
|
||||
setIsRunning(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const startScript = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
action: 'start',
|
||||
scriptPath,
|
||||
executionId
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const stopScript = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
action: 'stop',
|
||||
executionId
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const clearOutput = () => {
|
||||
setOutput([]);
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom when new output arrives
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
||||
{/* Terminal Header */}
|
||||
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
</div>
|
||||
<span className="text-gray-300 font-mono text-sm ml-2">
|
||||
{scriptName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span className="text-gray-400 text-xs">
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
ref={outputRef}
|
||||
className="h-96 overflow-y-auto p-4 font-mono text-sm"
|
||||
style={{ backgroundColor: '#000000', color: '#00ff00' }}
|
||||
>
|
||||
{output.length === 0 ? (
|
||||
<div className="text-gray-500">
|
||||
<p>Terminal ready. Click "Start Script" to begin execution.</p>
|
||||
<p className="mt-2">Script: {scriptPath}</p>
|
||||
</div>
|
||||
) : (
|
||||
output.map((line, index) => (
|
||||
<div key={index} className="whitespace-pre-wrap">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal Controls */}
|
||||
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-t border-gray-700">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={startScript}
|
||||
disabled={!isConnected || isRunning}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
isConnected && !isRunning
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
▶️ Start
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={stopScript}
|
||||
disabled={!isRunning}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
isRunning
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
⏹️ Stop
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={clearOutput}
|
||||
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
🗑️ Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/app/api/trpc/[trpc]/route.ts
Normal file
24
src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { env } from "~/env.js";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/trpc";
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => createTRPCContext({ headers: req.headers }),
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,14 +1,53 @@
|
||||
|
||||
'use client';
|
||||
|
||||
export default async function Home() {
|
||||
import { useState } from 'react';
|
||||
import { ScriptsList } from './_components/ScriptsList';
|
||||
import { RepoStatus } from './_components/RepoStatus';
|
||||
import { Terminal } from './_components/Terminal';
|
||||
|
||||
export default function Home() {
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string } | null>(null);
|
||||
|
||||
const handleRunScript = (scriptPath: string, scriptName: string) => {
|
||||
setRunningScript({ path: scriptPath, name: scriptName });
|
||||
};
|
||||
|
||||
const handleCloseTerminal = () => {
|
||||
setRunningScript(null);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-400 text-white">
|
||||
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16">
|
||||
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">PVE Scripts local</h1>
|
||||
<main className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||
🚀 PVE Scripts Local Management
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Repository Status */}
|
||||
<div className="mb-8">
|
||||
<RepoStatus />
|
||||
</div>
|
||||
|
||||
{/* Running Script Terminal */}
|
||||
{runningScript && (
|
||||
<div className="mb-8">
|
||||
<Terminal
|
||||
scriptPath={runningScript.path}
|
||||
onClose={handleCloseTerminal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scripts List */}
|
||||
<ScriptsList onRunScript={handleRunScript} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/env.js
20
src/env.js
@@ -11,6 +11,16 @@ export const env = createEnv({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
// Repository Configuration
|
||||
REPO_URL: z.string().url().optional(),
|
||||
REPO_BRANCH: z.string().default("main"),
|
||||
SCRIPTS_DIRECTORY: z.string().default("scripts"),
|
||||
ALLOWED_SCRIPT_EXTENSIONS: z.string().default(".sh,.py,.js,.ts,.bash"),
|
||||
// Security
|
||||
MAX_SCRIPT_EXECUTION_TIME: z.string().default("300000"), // 5 minutes in ms
|
||||
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
|
||||
// WebSocket Configuration
|
||||
WEBSOCKET_PORT: z.string().default("3001"),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -29,6 +39,16 @@ export const env = createEnv({
|
||||
runtimeEnv: {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
// Repository Configuration
|
||||
REPO_URL: process.env.REPO_URL,
|
||||
REPO_BRANCH: process.env.REPO_BRANCH,
|
||||
SCRIPTS_DIRECTORY: process.env.SCRIPTS_DIRECTORY,
|
||||
ALLOWED_SCRIPT_EXTENSIONS: process.env.ALLOWED_SCRIPT_EXTENSIONS,
|
||||
// Security
|
||||
MAX_SCRIPT_EXECUTION_TIME: process.env.MAX_SCRIPT_EXECUTION_TIME,
|
||||
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
|
||||
// WebSocket Configuration
|
||||
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { postRouter } from "~/server/api/routers/post";
|
||||
import { scriptsRouter } from "~/server/api/routers/scripts";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
/**
|
||||
@@ -7,7 +7,7 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
* All routers added in /api/routers should be manually added here.
|
||||
*/
|
||||
export const appRouter = createTRPCRouter({
|
||||
post: postRouter,
|
||||
scripts: scriptsRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
59
src/server/api/routers/scripts.ts
Normal file
59
src/server/api/routers/scripts.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { scriptManager } from "~/server/lib/scripts";
|
||||
import { gitManager } from "~/server/lib/git";
|
||||
|
||||
export const scriptsRouter = createTRPCRouter({
|
||||
// Get all available scripts
|
||||
getScripts: publicProcedure
|
||||
.query(async () => {
|
||||
const scripts = await scriptManager.getScripts();
|
||||
return {
|
||||
scripts,
|
||||
directoryInfo: scriptManager.getScriptsDirectoryInfo()
|
||||
};
|
||||
}),
|
||||
|
||||
// Get repository status
|
||||
getRepoStatus: publicProcedure
|
||||
.query(async () => {
|
||||
const status = await gitManager.getStatus();
|
||||
return status;
|
||||
}),
|
||||
|
||||
// Update repository
|
||||
updateRepo: publicProcedure
|
||||
.mutation(async () => {
|
||||
const result = await gitManager.pullUpdates();
|
||||
return result;
|
||||
}),
|
||||
|
||||
// Get script content
|
||||
getScriptContent: publicProcedure
|
||||
.input(z.object({ scriptPath: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const content = await scriptManager.getScriptContent(input.scriptPath);
|
||||
return { success: true, content };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Validate script path
|
||||
validateScript: publicProcedure
|
||||
.input(z.object({ scriptPath: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const validation = scriptManager.validateScriptPath(input.scriptPath);
|
||||
return validation;
|
||||
}),
|
||||
|
||||
// Get directory information
|
||||
getDirectoryInfo: publicProcedure
|
||||
.query(async () => {
|
||||
return scriptManager.getScriptsDirectoryInfo();
|
||||
})
|
||||
});
|
||||
215
src/server/api/websocket/handler.ts
Normal file
215
src/server/api/websocket/handler.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { scriptManager } from '~/server/lib/scripts';
|
||||
import { env } from '~/env.js';
|
||||
|
||||
interface ScriptExecutionMessage {
|
||||
type: 'start' | 'output' | 'error' | 'end';
|
||||
data: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class ScriptExecutionHandler {
|
||||
private wss: WebSocketServer;
|
||||
private activeExecutions: Map<string, { process: any; ws: WebSocket }> = new Map();
|
||||
|
||||
constructor(server: any) {
|
||||
this.wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/ws/script-execution'
|
||||
});
|
||||
|
||||
this.wss.on('connection', this.handleConnection.bind(this));
|
||||
}
|
||||
|
||||
private handleConnection(ws: WebSocket, request: IncomingMessage) {
|
||||
console.log('New WebSocket connection for script execution');
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleMessage(ws, message);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Invalid message format',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket connection closed');
|
||||
// Clean up any active executions for this connection
|
||||
this.cleanupActiveExecutions(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.cleanupActiveExecutions(ws);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessage(ws: WebSocket, message: any) {
|
||||
const { action, scriptPath, executionId } = message;
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
if (scriptPath && executionId) {
|
||||
await this.startScriptExecution(ws, scriptPath, executionId);
|
||||
} else {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Missing scriptPath or executionId',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
if (executionId) {
|
||||
this.stopScriptExecution(executionId);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Unknown action',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string) {
|
||||
try {
|
||||
// Validate script path
|
||||
const validation = scriptManager.validateScriptPath(scriptPath);
|
||||
if (!validation.valid) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: validation.message || 'Invalid script path',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if execution is already running
|
||||
if (this.activeExecutions.has(executionId)) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Script execution already running',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start script execution
|
||||
const process = await scriptManager.executeScript(scriptPath);
|
||||
|
||||
// Store the execution
|
||||
this.activeExecutions.set(executionId, { process, ws });
|
||||
|
||||
// Send start message
|
||||
this.sendMessage(ws, {
|
||||
type: 'start',
|
||||
data: `Starting execution of ${scriptPath}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Handle stdout
|
||||
process.stdout?.on('data', (data: Buffer) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: data.toString(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
process.stderr?.on('data', (data: Buffer) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: data.toString(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
process.on('exit', (code: number | null, signal: string | null) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'end',
|
||||
data: `Script execution finished with code: ${code}, signal: ${signal}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Clean up
|
||||
this.activeExecutions.delete(executionId);
|
||||
});
|
||||
|
||||
// Handle process error
|
||||
process.on('error', (error: Error) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `Process error: ${error.message}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Clean up
|
||||
this.activeExecutions.delete(executionId);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `Failed to start script: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private stopScriptExecution(executionId: string) {
|
||||
const execution = this.activeExecutions.get(executionId);
|
||||
if (execution) {
|
||||
execution.process.kill('SIGTERM');
|
||||
this.activeExecutions.delete(executionId);
|
||||
|
||||
this.sendMessage(execution.ws, {
|
||||
type: 'end',
|
||||
data: 'Script execution stopped by user',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupActiveExecutions(ws: WebSocket) {
|
||||
for (const [executionId, execution] of this.activeExecutions.entries()) {
|
||||
if (execution.ws === ws) {
|
||||
execution.process.kill('SIGTERM');
|
||||
this.activeExecutions.delete(executionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get active executions count
|
||||
getActiveExecutionsCount(): number {
|
||||
return this.activeExecutions.size;
|
||||
}
|
||||
|
||||
// Get active executions info
|
||||
getActiveExecutions(): string[] {
|
||||
return Array.from(this.activeExecutions.keys());
|
||||
}
|
||||
}
|
||||
|
||||
// Export function to create handler
|
||||
export function createScriptExecutionHandler(server: any): ScriptExecutionHandler {
|
||||
return new ScriptExecutionHandler(server);
|
||||
}
|
||||
177
src/server/lib/git.ts
Normal file
177
src/server/lib/git.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { simpleGit, SimpleGit } from 'simple-git';
|
||||
import { env } from '~/env.js';
|
||||
import { join } from 'path';
|
||||
|
||||
export class GitManager {
|
||||
private git: SimpleGit;
|
||||
private repoPath: string;
|
||||
private scriptsDir: string;
|
||||
|
||||
constructor() {
|
||||
this.repoPath = process.cwd();
|
||||
this.scriptsDir = join(this.repoPath, env.SCRIPTS_DIRECTORY);
|
||||
this.git = simpleGit(this.repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the repository is behind the remote
|
||||
*/
|
||||
async isBehindRemote(): Promise<boolean> {
|
||||
try {
|
||||
if (!env.REPO_URL) {
|
||||
return false; // No remote configured
|
||||
}
|
||||
|
||||
// Fetch latest changes without merging
|
||||
await this.git.fetch();
|
||||
|
||||
// Check if local branch is behind remote
|
||||
const status = await this.git.status();
|
||||
const behind = status.behind > 0;
|
||||
|
||||
return behind;
|
||||
} catch (error) {
|
||||
console.error('Error checking repo status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull updates from remote repository
|
||||
*/
|
||||
async pullUpdates(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (!env.REPO_URL) {
|
||||
return { success: false, message: 'No remote repository configured' };
|
||||
}
|
||||
|
||||
// Check if we're in a git repository
|
||||
const isRepo = await this.git.checkIsRepo();
|
||||
if (!isRepo) {
|
||||
// Clone the repository if it doesn't exist
|
||||
return await this.cloneRepository();
|
||||
}
|
||||
|
||||
// Pull latest changes
|
||||
const result = await this.git.pull(env.REPO_BRANCH);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully pulled updates. ${result.summary.changes} changes, ${result.summary.insertions} insertions, ${result.summary.deletions} deletions`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error pulling updates:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to pull updates: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the repository if it doesn't exist
|
||||
*/
|
||||
private async cloneRepository(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (!env.REPO_URL) {
|
||||
return { success: false, message: 'No repository URL configured' };
|
||||
}
|
||||
|
||||
console.log(`Cloning repository from ${env.REPO_URL}...`);
|
||||
|
||||
// Clone the repository
|
||||
await this.git.clone(env.REPO_URL, this.repoPath, {
|
||||
'--branch': env.REPO_BRANCH,
|
||||
'--single-branch': true,
|
||||
'--depth': 1
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully cloned repository from ${env.REPO_URL}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error cloning repository:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to clone repository: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize repository on startup if needed
|
||||
*/
|
||||
async initializeRepository(): Promise<void> {
|
||||
try {
|
||||
if (!env.REPO_URL) {
|
||||
console.log('No remote repository configured, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
const isRepo = await this.git.checkIsRepo();
|
||||
if (!isRepo) {
|
||||
console.log('Repository not found, cloning...');
|
||||
const result = await this.cloneRepository();
|
||||
if (result.success) {
|
||||
console.log('Repository initialized successfully');
|
||||
} else {
|
||||
console.error('Failed to initialize repository:', result.message);
|
||||
}
|
||||
} else {
|
||||
console.log('Repository already exists, checking for updates...');
|
||||
const behind = await this.isBehindRemote();
|
||||
if (behind) {
|
||||
console.log('Repository is behind remote, pulling updates...');
|
||||
const result = await this.pullUpdates();
|
||||
if (result.success) {
|
||||
console.log('Repository updated successfully');
|
||||
} else {
|
||||
console.error('Failed to update repository:', result.message);
|
||||
}
|
||||
} else {
|
||||
console.log('Repository is up to date');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing repository:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository status information
|
||||
*/
|
||||
async getStatus(): Promise<{
|
||||
isRepo: boolean;
|
||||
isBehind: boolean;
|
||||
lastCommit?: string;
|
||||
branch?: string;
|
||||
}> {
|
||||
try {
|
||||
const isRepo = await this.git.checkIsRepo();
|
||||
if (!isRepo) {
|
||||
return { isRepo: false, isBehind: false };
|
||||
}
|
||||
|
||||
const isBehind = await this.isBehindRemote();
|
||||
const log = await this.git.log({ maxCount: 1 });
|
||||
const status = await this.git.status();
|
||||
|
||||
return {
|
||||
isRepo: true,
|
||||
isBehind,
|
||||
lastCommit: log.latest?.hash,
|
||||
branch: status.current
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting repository status:', error);
|
||||
return { isRepo: false, isBehind: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const gitManager = new GitManager();
|
||||
|
||||
// Initialize repository on module load
|
||||
gitManager.initializeRepository().catch(console.error);
|
||||
212
src/server/lib/scripts.ts
Normal file
212
src/server/lib/scripts.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { readdir, stat, access } from 'fs/promises';
|
||||
import { join, resolve, extname } from 'path';
|
||||
import { env } from '~/env.js';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
|
||||
export interface ScriptInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
extension: string;
|
||||
size: number;
|
||||
lastModified: Date;
|
||||
executable: boolean;
|
||||
}
|
||||
|
||||
export class ScriptManager {
|
||||
private scriptsDir: string;
|
||||
private allowedExtensions: string[];
|
||||
private allowedPaths: string[];
|
||||
private maxExecutionTime: number;
|
||||
|
||||
constructor() {
|
||||
this.scriptsDir = join(process.cwd(), env.SCRIPTS_DIRECTORY);
|
||||
this.allowedExtensions = env.ALLOWED_SCRIPT_EXTENSIONS.split(',').map(ext => ext.trim());
|
||||
this.allowedPaths = env.ALLOWED_SCRIPT_PATHS.split(',').map(path => path.trim());
|
||||
this.maxExecutionTime = parseInt(env.MAX_SCRIPT_EXECUTION_TIME, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available scripts in the scripts directory
|
||||
*/
|
||||
async getScripts(): Promise<ScriptInfo[]> {
|
||||
try {
|
||||
const files = await readdir(this.scriptsDir);
|
||||
const scripts: ScriptInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(this.scriptsDir, file);
|
||||
const stats = await stat(filePath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
const extension = extname(file);
|
||||
|
||||
// Check if file extension is allowed
|
||||
if (this.allowedExtensions.includes(extension)) {
|
||||
// Check if file is executable
|
||||
const executable = await this.isExecutable(filePath);
|
||||
|
||||
scripts.push({
|
||||
name: file,
|
||||
path: filePath,
|
||||
extension,
|
||||
size: stats.size,
|
||||
lastModified: stats.mtime,
|
||||
executable
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scripts.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch (error) {
|
||||
console.error('Error reading scripts directory:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is executable
|
||||
*/
|
||||
private async isExecutable(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, 0o111); // Check execute permission
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a script path is allowed to be executed
|
||||
*/
|
||||
validateScriptPath(scriptPath: string): { valid: boolean; message?: string } {
|
||||
const resolvedPath = resolve(scriptPath);
|
||||
const scriptsDirResolved = resolve(this.scriptsDir);
|
||||
|
||||
// Check if the script is within the allowed directory
|
||||
if (!resolvedPath.startsWith(scriptsDirResolved)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Script path is not within the allowed scripts directory'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if the script path matches any allowed path pattern
|
||||
const relativePath = resolvedPath.replace(scriptsDirResolved, '').replace(/\\/g, '/');
|
||||
const isAllowed = this.allowedPaths.some(allowedPath =>
|
||||
relativePath.startsWith(allowedPath.replace(/\\/g, '/'))
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Script path is not in the allowed paths list'
|
||||
};
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
const extension = extname(scriptPath);
|
||||
if (!this.allowedExtensions.includes(extension)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `File extension '${extension}' is not allowed. Allowed extensions: ${this.allowedExtensions.join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a script and return a child process
|
||||
*/
|
||||
async executeScript(scriptPath: string): Promise<ChildProcess> {
|
||||
const validation = this.validateScriptPath(scriptPath);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.message);
|
||||
}
|
||||
|
||||
// Determine the command to run based on file extension
|
||||
const extension = extname(scriptPath);
|
||||
let command: string;
|
||||
let args: string[] = [];
|
||||
|
||||
switch (extension) {
|
||||
case '.sh':
|
||||
case '.bash':
|
||||
command = 'bash';
|
||||
args = [scriptPath];
|
||||
break;
|
||||
case '.py':
|
||||
command = 'python';
|
||||
args = [scriptPath];
|
||||
break;
|
||||
case '.js':
|
||||
command = 'node';
|
||||
args = [scriptPath];
|
||||
break;
|
||||
case '.ts':
|
||||
command = 'npx';
|
||||
args = ['ts-node', scriptPath];
|
||||
break;
|
||||
default:
|
||||
// Try to execute directly (for files with shebang)
|
||||
command = scriptPath;
|
||||
args = [];
|
||||
}
|
||||
|
||||
// Spawn the process
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd: this.scriptsDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: true
|
||||
});
|
||||
|
||||
// Set up timeout
|
||||
const timeout = setTimeout(() => {
|
||||
if (!childProcess.killed) {
|
||||
childProcess.kill('SIGTERM');
|
||||
console.log(`Script execution timed out after ${this.maxExecutionTime}ms`);
|
||||
}
|
||||
}, this.maxExecutionTime);
|
||||
|
||||
// Clean up timeout when process exits
|
||||
childProcess.on('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
|
||||
return childProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get script content for display
|
||||
*/
|
||||
async getScriptContent(scriptPath: string): Promise<string> {
|
||||
const validation = this.validateScriptPath(scriptPath);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.message);
|
||||
}
|
||||
|
||||
const { readFile } = await import('fs/promises');
|
||||
return await readFile(scriptPath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scripts directory information
|
||||
*/
|
||||
getScriptsDirectoryInfo(): {
|
||||
path: string;
|
||||
allowedExtensions: string[];
|
||||
allowedPaths: string[];
|
||||
maxExecutionTime: number;
|
||||
} {
|
||||
return {
|
||||
path: this.scriptsDir,
|
||||
allowedExtensions: this.allowedExtensions,
|
||||
allowedPaths: this.allowedPaths,
|
||||
maxExecutionTime: this.maxExecutionTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const scriptManager = new ScriptManager();
|
||||
Reference in New Issue
Block a user