Compare commits
71 Commits
bump_pve91
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3847fb5e | ||
|
|
0da802be42 | ||
|
|
5bc3933d11 | ||
|
|
1c6d1ac120 | ||
|
|
ba1e6478d7 | ||
|
|
e3af248456 | ||
|
|
43bafb610f | ||
|
|
8e22568efb | ||
|
|
6bb9ed5182 | ||
|
|
b6c3954f98 | ||
|
|
f73b303172 | ||
|
|
50d066669e | ||
|
|
68541c0046 | ||
|
|
644222e958 | ||
|
|
31a5fd97d4 | ||
|
|
b54fbf15f6 | ||
|
|
a787e60e7c | ||
|
|
1e250306dc | ||
|
|
d64a296ebe | ||
|
|
691b27c924 | ||
|
|
dbc591aa63 | ||
|
|
5ea6828f8c | ||
|
|
3dabacd055 | ||
|
|
e8ee829577 | ||
|
|
aebc8a6171 | ||
|
|
c5db169441 | ||
|
|
bef5bef875 | ||
|
|
3a4f86942f | ||
|
|
94eb772467 | ||
|
|
3a2a1b2cd6 | ||
|
|
69c10b05ac | ||
|
|
7833d5d408 | ||
|
|
e0baa79d6b | ||
|
|
737c9c94f3 | ||
|
|
c57586acae | ||
|
|
74030b5806 | ||
|
|
cc276ddff3 | ||
|
|
375c551a3a | ||
|
|
e3e4556f83 | ||
|
|
7fa132e09c | ||
|
|
1a1dbe6975 | ||
|
|
1a5881c935 | ||
|
|
2d7176914e | ||
|
|
987ac3da1b | ||
|
|
03e31d66a7 | ||
|
|
7547dff67d | ||
|
|
1945b14694 | ||
|
|
ec23600861 | ||
|
|
41a9c0ae11 | ||
|
|
c266c4cb3c | ||
|
|
b5bce88398 | ||
|
|
48cf86a449 | ||
|
|
d40aeb6c82 | ||
|
|
9c759ba99b | ||
|
|
f467b9ad7b | ||
|
|
7fe2a8b453 | ||
|
|
5274737ab8 | ||
|
|
40805f39f7 | ||
|
|
f9af7536d0 | ||
|
|
0d39a9bbd0 | ||
|
|
66f8a84260 | ||
|
|
2a9921a4e1 | ||
|
|
50f657ba00 | ||
|
|
5d5eba72de | ||
|
|
577b96518e | ||
|
|
c6c27271d6 | ||
|
|
72c0246d8c | ||
|
|
06d4786e0a | ||
|
|
bc31896586 | ||
|
|
213a606fc0 | ||
|
|
3579f2258e |
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:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,6 +16,9 @@
|
||||
db.sqlite
|
||||
data/settings.db
|
||||
|
||||
# prisma generated client
|
||||
/prisma/generated/
|
||||
|
||||
# ssh keys (sensitive)
|
||||
data/ssh-keys/
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import eslintPluginNext from "@next/eslint-plugin-next";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
});
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [".next"],
|
||||
ignores: [".next", "next-env.d.ts", "postcss.config.js", "prettier.config.js"],
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
"@next/next": eslintPluginNext,
|
||||
"react": reactPlugin,
|
||||
"react-hooks": reactHooksPlugin,
|
||||
},
|
||||
rules: {
|
||||
...eslintPluginNext.configs.recommended.rules,
|
||||
...eslintPluginNext.configs["core-web-vitals"].rules,
|
||||
},
|
||||
},
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
extends: [
|
||||
|
||||
@@ -18,31 +18,25 @@ const config = {
|
||||
},
|
||||
],
|
||||
},
|
||||
// Allow cross-origin requests from local network ranges
|
||||
allowedDevOrigins: [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://[::1]:3000',
|
||||
'http://10.*',
|
||||
'http://172.16.*',
|
||||
'http://172.17.*',
|
||||
'http://172.18.*',
|
||||
'http://172.19.*',
|
||||
'http://172.20.*',
|
||||
'http://172.21.*',
|
||||
'http://172.22.*',
|
||||
'http://172.23.*',
|
||||
'http://172.24.*',
|
||||
'http://172.25.*',
|
||||
'http://172.26.*',
|
||||
'http://172.27.*',
|
||||
'http://172.28.*',
|
||||
'http://172.29.*',
|
||||
'http://172.30.*',
|
||||
'http://172.31.*',
|
||||
'http://192.168.*',
|
||||
],
|
||||
// Allow cross-origin requests from local network in dev mode
|
||||
// Note: In Next.js 16, we disable this check entirely for dev
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
turbopack: {
|
||||
// Disable Turbopack and use Webpack instead for compatibility
|
||||
// This is necessary for server-side code that uses child_process
|
||||
},
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
if (dev && !isServer) {
|
||||
config.watchOptions = {
|
||||
@@ -50,15 +44,18 @@ const config = {
|
||||
aggregateTimeout: 300,
|
||||
};
|
||||
}
|
||||
// Handle server-side modules
|
||||
if (isServer) {
|
||||
config.externals = config.externals || [];
|
||||
if (!config.externals.includes('child_process')) {
|
||||
config.externals.push('child_process');
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
// Ignore ESLint errors during build (they can be fixed separately)
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
// Ignore TypeScript errors during build (they can be fixed separately)
|
||||
// TypeScript errors will fail the build
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
3710
package-lock.json
generated
3710
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
92
package.json
92
package.json
@@ -4,17 +4,20 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"dev": "next dev",
|
||||
"dev:server": "node server.js",
|
||||
"dev:next": "next dev",
|
||||
"build": "prisma generate && next build --webpack",
|
||||
"check": "eslint . && tsc --noEmit",
|
||||
"dev": "next dev --webpack",
|
||||
"dev:server": "node --import tsx server.js",
|
||||
"dev:next": "next dev --webpack",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"generate": "prisma generate",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"migrate": "prisma migrate dev",
|
||||
"preview": "next build && next start",
|
||||
"start": "node server.js",
|
||||
"postinstall": "prisma generate",
|
||||
"start": "node --import tsx server.js",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
@@ -22,77 +25,82 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.18.0",
|
||||
"@prisma/adapter-better-sqlite3": "^7.0.1",
|
||||
"@prisma/client": "^7.0.1",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@trpc/client": "^11.7.2",
|
||||
"@trpc/react-query": "^11.7.2",
|
||||
"@trpc/server": "^11.7.2",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cron-validator": "^1.2.0",
|
||||
"cron-validator": "^1.4.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^15.1.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "^16.0.5",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.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",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"superjson": "^2.2.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.1.12"
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-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",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.14",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^15.1.6",
|
||||
"eslint-config-next": "^16.0.5",
|
||||
"jsdom": "^27.2.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.7.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"prisma": "^6.19.0",
|
||||
"prisma": "^7.0.1",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"vitest": "^3.2.4"
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"tsx": "^4.19.4",
|
||||
"vitest": "^4.0.14"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"packageManager": "npm@10.9.3",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"prismjs": "^1.30.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
prisma.config.ts
Normal file
20
prisma.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'dotenv/config'
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'prisma/config'
|
||||
|
||||
// Resolve database path
|
||||
const dbPath = process.env.DATABASE_URL ?? `file:${path.join(process.cwd(), 'data', 'pve-scripts.db')}`
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
datasource: {
|
||||
url: dbPath,
|
||||
},
|
||||
// @ts-expect-error - Prisma 7 config types are incomplete
|
||||
studio: {
|
||||
adapter: async () => {
|
||||
const { PrismaBetterSqlite3 } = await import('@prisma/adapter-better-sqlite3')
|
||||
return new PrismaBetterSqlite3({ url: dbPath })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client"
|
||||
output = "./generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model InstalledScript {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
103
server.js
103
server.js
@@ -8,9 +8,12 @@ import stripAnsi from 'strip-ansi';
|
||||
import { spawn as ptySpawn } from 'node-pty';
|
||||
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
||||
import { getDatabase } from './src/server/database-prisma.js';
|
||||
import { initializeAutoSync, initializeRepositories, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Dynamic import for auto sync init to avoid tsx caching issues
|
||||
/** @type {any} */
|
||||
let autoSyncModule = null;
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
// Fallback minimal global error handlers for Node runtime (avoid TS import)
|
||||
@@ -71,7 +74,10 @@ const handle = app.getRequestHandler();
|
||||
* @property {ServerInfo} [server]
|
||||
* @property {boolean} [isUpdate]
|
||||
* @property {boolean} [isShell]
|
||||
* @property {boolean} [isBackup]
|
||||
* @property {string} [containerId]
|
||||
* @property {string} [storage]
|
||||
* @property {string} [backupStorage]
|
||||
*/
|
||||
|
||||
class ScriptExecutionHandler {
|
||||
@@ -79,14 +85,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
|
||||
@@ -707,7 +726,7 @@ class ScriptExecutionHandler {
|
||||
* @param {ServerInfo} server
|
||||
* @param {Function} [onComplete] - Optional callback when backup completes
|
||||
*/
|
||||
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
|
||||
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = undefined) {
|
||||
const sshService = getSSHExecutionService();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -819,10 +838,10 @@ class ScriptExecutionHandler {
|
||||
* @param {string} containerId
|
||||
* @param {string} executionId
|
||||
* @param {string} mode
|
||||
* @param {ServerInfo|null} server
|
||||
* @param {ServerInfo|undefined} server
|
||||
* @param {string} [backupStorage] - Optional storage to backup to before update
|
||||
*/
|
||||
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) {
|
||||
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = undefined, backupStorage = undefined) {
|
||||
try {
|
||||
// If backup storage is provided, run backup first
|
||||
if (backupStorage && mode === 'ssh' && server) {
|
||||
@@ -1159,12 +1178,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 +1204,33 @@ app.prepare().then(() => {
|
||||
|
||||
// Create WebSocket handlers
|
||||
const scriptHandler = new ScriptExecutionHandler(httpServer);
|
||||
|
||||
// Handle WebSocket upgrades manually to avoid interfering with Next.js HMR
|
||||
// We need to preserve Next.js's upgrade handlers and call them for non-matching paths
|
||||
// Save any existing upgrade listeners (Next.js might have set them up)
|
||||
const existingUpgradeListeners = httpServer.listeners('upgrade').slice();
|
||||
httpServer.removeAllListeners('upgrade');
|
||||
|
||||
// Add our upgrade handler that routes based on path
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
const parsedUrl = parse(request.url || '', true);
|
||||
const { pathname } = parsedUrl;
|
||||
|
||||
if (pathname === '/ws/script-execution') {
|
||||
// Handle our custom WebSocket endpoint
|
||||
scriptHandler.handleUpgrade(request, socket, head);
|
||||
} else {
|
||||
// For all other paths (including Next.js HMR), call existing listeners
|
||||
// This allows Next.js to handle its own WebSocket upgrades
|
||||
for (const listener of existingUpgradeListeners) {
|
||||
try {
|
||||
listener.call(httpServer, request, socket, head);
|
||||
} catch (err) {
|
||||
console.error('Error in upgrade listener:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Note: TerminalHandler removed as it's not being used by the current application
|
||||
|
||||
httpServer
|
||||
@@ -1186,13 +1242,38 @@ app.prepare().then(() => {
|
||||
console.log(`> Ready on http://${hostname}:${port}`);
|
||||
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
|
||||
|
||||
// Initialize auto sync module and run initialization
|
||||
if (!autoSyncModule) {
|
||||
try {
|
||||
console.log('Dynamically importing autoSyncInit...');
|
||||
autoSyncModule = await import('./src/server/lib/autoSyncInit.js');
|
||||
console.log('autoSyncModule loaded, exports:', Object.keys(autoSyncModule));
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
console.error('Failed to import autoSyncInit:', err.message);
|
||||
console.error('Stack:', err.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize default repositories
|
||||
await initializeRepositories();
|
||||
if (typeof autoSyncModule.initializeRepositories === 'function') {
|
||||
console.log('Calling initializeRepositories...');
|
||||
await autoSyncModule.initializeRepositories();
|
||||
} else {
|
||||
console.warn('initializeRepositories is not a function, type:', typeof autoSyncModule.initializeRepositories);
|
||||
}
|
||||
|
||||
// Initialize auto-sync service
|
||||
initializeAutoSync();
|
||||
if (typeof autoSyncModule.initializeAutoSync === 'function') {
|
||||
console.log('Calling initializeAutoSync...');
|
||||
autoSyncModule.initializeAutoSync();
|
||||
}
|
||||
|
||||
// Setup graceful shutdown handlers
|
||||
setupGracefulShutdown();
|
||||
if (typeof autoSyncModule.setupGracefulShutdown === 'function') {
|
||||
console.log('Setting up graceful shutdown...');
|
||||
autoSyncModule.setupGracefulShutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
@@ -27,10 +34,13 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const checkAuthInternal = async (retryCount = 0) => {
|
||||
try {
|
||||
// First check if setup is completed
|
||||
const setupResponse = await fetch('/api/settings/auth-credentials');
|
||||
const setupResponse = await fetch("/api/settings/auth-credentials");
|
||||
if (setupResponse.ok) {
|
||||
const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
|
||||
|
||||
const setupData = (await setupResponse.json()) as {
|
||||
setupCompleted: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
// If setup is not completed or auth is disabled, don't verify
|
||||
if (!setupData.setupCompleted || !setupData.enabled) {
|
||||
setIsAuthenticated(false);
|
||||
@@ -42,12 +52,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
}
|
||||
|
||||
// Only verify authentication if setup is completed and auth is enabled
|
||||
const response = await fetch('/api/auth/verify', {
|
||||
credentials: 'include', // Ensure cookies are sent
|
||||
const response = await fetch("/api/auth/verify", {
|
||||
credentials: "include", // Ensure cookies are sent
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as {
|
||||
username: string;
|
||||
const data = (await response.json()) as {
|
||||
username: string;
|
||||
expirationTime?: number | null;
|
||||
timeUntilExpiration?: number | null;
|
||||
};
|
||||
@@ -58,7 +68,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
|
||||
|
||||
// Retry logic for failed auth checks (max 2 retries)
|
||||
if (retryCount < 2) {
|
||||
setTimeout(() => {
|
||||
@@ -68,11 +78,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking auth:', error);
|
||||
console.error("Error checking auth:", error);
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
|
||||
|
||||
// Retry logic for network errors (max 2 retries)
|
||||
if (retryCount < 2) {
|
||||
setTimeout(() => {
|
||||
@@ -89,44 +99,49 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
return checkAuthInternal(0);
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
const login = async (
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: 'include', // Ensure cookies are received
|
||||
credentials: "include", // Ensure cookies are received
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { username: string };
|
||||
const data = (await response.json()) as {
|
||||
username: string;
|
||||
expirationTime?: number;
|
||||
};
|
||||
setIsAuthenticated(true);
|
||||
setUsername(data.username);
|
||||
|
||||
// Check auth again to get expiration time
|
||||
// Add a small delay to ensure the httpOnly cookie is available
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
void checkAuth().then(() => resolve());
|
||||
}, 150);
|
||||
});
|
||||
// Set expiration time from login response if available
|
||||
if (data.expirationTime) {
|
||||
setExpirationTime(data.expirationTime);
|
||||
}
|
||||
// Don't call checkAuth after login - we already know we're authenticated
|
||||
// The cookie is set by the server response
|
||||
return true;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error('Login failed:', errorData.error);
|
||||
console.error("Login failed:", errorData.error);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
console.error("Login error:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
// Clear the auth cookie by setting it to expire
|
||||
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie =
|
||||
"auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
@@ -156,7 +171,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { Button } from "./ui/button";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
|
||||
interface BackupWarningModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -13,33 +13,43 @@ interface BackupWarningModalProps {
|
||||
export function BackupWarningModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onProceed
|
||||
onProceed,
|
||||
}: BackupWarningModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose });
|
||||
useRegisterModal(isOpen, {
|
||||
id: "backup-warning-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="border-border flex items-center justify-center border-b p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-8 w-8 text-warning" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Backup Failed</h2>
|
||||
<AlertTriangle className="text-warning h-8 w-8" />
|
||||
<h2 className="text-card-foreground text-2xl font-bold">
|
||||
Backup Failed
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
The backup failed, but you can still proceed with the update if you wish.
|
||||
<br /><br />
|
||||
<strong className="text-foreground">Warning:</strong> Proceeding without a backup means you won't be able to restore the container if something goes wrong during the update.
|
||||
<p className="text-muted-foreground mb-6 text-sm">
|
||||
The backup failed, but you can still proceed with the update if you
|
||||
wish.
|
||||
<br />
|
||||
<br />
|
||||
<strong className="text-foreground">Warning:</strong> Proceeding
|
||||
without a backup means you won't be able to restore the
|
||||
container if something goes wrong during the update.
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
||||
<div className="flex flex-col justify-end gap-3 sm:flex-row">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
@@ -52,7 +62,7 @@ export function BackupWarningModal({
|
||||
onClick={onProceed}
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto bg-warning hover:bg-warning/90"
|
||||
className="bg-warning hover:bg-warning/90 w-full sm:w-auto"
|
||||
>
|
||||
Proceed Anyway
|
||||
</Button>
|
||||
@@ -62,6 +72,3 @@ export function BackupWarningModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
import {
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
HardDrive,
|
||||
Database,
|
||||
Server,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { LoadingModal } from './LoadingModal';
|
||||
} from "./ui/dropdown-menu";
|
||||
import { ConfirmationModal } from "./ConfirmationModal";
|
||||
import { LoadingModal } from "./LoadingModal";
|
||||
|
||||
interface Backup {
|
||||
id: number;
|
||||
@@ -23,7 +32,7 @@ interface Backup {
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
server_id: number;
|
||||
server_id?: number;
|
||||
server_name: string | null;
|
||||
server_color: string | null;
|
||||
}
|
||||
@@ -35,16 +44,25 @@ interface ContainerBackups {
|
||||
}
|
||||
|
||||
export function BackupsTab() {
|
||||
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set());
|
||||
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false);
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
|
||||
const [selectedBackup, setSelectedBackup] = useState<{ backup: Backup; containerId: string } | null>(null);
|
||||
const [selectedBackup, setSelectedBackup] = useState<{
|
||||
backup: Backup;
|
||||
containerId: string;
|
||||
} | null>(null);
|
||||
const [restoreProgress, setRestoreProgress] = useState<string[]>([]);
|
||||
const [restoreSuccess, setRestoreSuccess] = useState(false);
|
||||
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||
const [shouldPollRestore, setShouldPollRestore] = useState(false);
|
||||
|
||||
const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
|
||||
const {
|
||||
data: backupsData,
|
||||
refetch: refetchBackups,
|
||||
isLoading,
|
||||
} = api.backups.getAllBackupsGrouped.useQuery();
|
||||
const discoverMutation = api.backups.discoverBackups.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetchBackups();
|
||||
@@ -52,26 +70,30 @@ export function BackupsTab() {
|
||||
});
|
||||
|
||||
// Poll for restore progress
|
||||
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, {
|
||||
enabled: shouldPollRestore,
|
||||
refetchInterval: 1000, // Poll every second
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: shouldPollRestore,
|
||||
refetchInterval: 1000, // Poll every second
|
||||
refetchIntervalInBackground: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Update restore progress when log data changes
|
||||
useEffect(() => {
|
||||
if (restoreLogsData?.success && restoreLogsData.logs) {
|
||||
setRestoreProgress(restoreLogsData.logs);
|
||||
|
||||
|
||||
// Stop polling when restore is complete
|
||||
if (restoreLogsData.isComplete) {
|
||||
setShouldPollRestore(false);
|
||||
// Check if restore was successful or failed
|
||||
const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || '';
|
||||
if (lastLog.includes('Restore completed successfully')) {
|
||||
const lastLog =
|
||||
restoreLogsData.logs[restoreLogsData.logs.length - 1] ?? "";
|
||||
if (lastLog.includes("Restore completed successfully")) {
|
||||
setRestoreSuccess(true);
|
||||
setRestoreError(null);
|
||||
} else if (lastLog.includes('Error:') || lastLog.includes('failed')) {
|
||||
} else if (lastLog.includes("Error:") || lastLog.includes("failed")) {
|
||||
setRestoreError(lastLog);
|
||||
setRestoreSuccess(false);
|
||||
}
|
||||
@@ -83,17 +105,22 @@ export function BackupsTab() {
|
||||
onMutate: () => {
|
||||
// Start polling for progress
|
||||
setShouldPollRestore(true);
|
||||
setRestoreProgress(['Starting restore...']);
|
||||
setRestoreProgress(["Starting restore..."]);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// Stop polling - progress will be updated from logs
|
||||
setShouldPollRestore(false);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
// Update progress with all messages from backend (fallback if polling didn't work)
|
||||
const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']);
|
||||
const progressMessages =
|
||||
restoreProgress.length > 0
|
||||
? restoreProgress
|
||||
: (result.progress?.map((p) => p.message) ?? [
|
||||
"Restore completed successfully",
|
||||
]);
|
||||
setRestoreProgress(progressMessages);
|
||||
setRestoreSuccess(true);
|
||||
setRestoreError(null);
|
||||
@@ -101,8 +128,10 @@ export function BackupsTab() {
|
||||
setSelectedBackup(null);
|
||||
// Keep success message visible - user can dismiss manually
|
||||
} else {
|
||||
setRestoreError(result.error || 'Restore failed');
|
||||
setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress);
|
||||
setRestoreError(result.error ?? "Restore failed");
|
||||
setRestoreProgress(
|
||||
result.progress?.map((p) => p.message) ?? restoreProgress,
|
||||
);
|
||||
setRestoreSuccess(false);
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
@@ -112,24 +141,25 @@ export function BackupsTab() {
|
||||
onError: (error) => {
|
||||
// Stop polling on error
|
||||
setShouldPollRestore(false);
|
||||
setRestoreError(error.message || 'Restore failed');
|
||||
setRestoreError(error.message ?? "Restore failed");
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
setRestoreProgress([]);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Update progress text in modal based on current progress
|
||||
const currentProgressText = restoreProgress.length > 0
|
||||
? restoreProgress[restoreProgress.length - 1]
|
||||
: 'Restoring backup...';
|
||||
const currentProgressText =
|
||||
restoreProgress.length > 0
|
||||
? restoreProgress[restoreProgress.length - 1]
|
||||
: "Restoring backup...";
|
||||
|
||||
// Auto-discover backups when tab is first opened
|
||||
useEffect(() => {
|
||||
if (!hasAutoDiscovered && !isLoading && backupsData) {
|
||||
// Only auto-discover if there are no backups yet
|
||||
if (!backupsData.backups || backupsData.backups.length === 0) {
|
||||
handleDiscoverBackups();
|
||||
if (!backupsData.backups?.length) {
|
||||
void handleDiscoverBackups();
|
||||
}
|
||||
setHasAutoDiscovered(true);
|
||||
}
|
||||
@@ -149,15 +179,15 @@ export function BackupsTab() {
|
||||
|
||||
const handleRestoreConfirm = () => {
|
||||
if (!selectedBackup) return;
|
||||
|
||||
|
||||
setRestoreConfirmOpen(false);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
|
||||
|
||||
restoreMutation.mutate({
|
||||
backupId: selectedBackup.backup.id,
|
||||
containerId: selectedBackup.containerId,
|
||||
serverId: selectedBackup.backup.server_id,
|
||||
serverId: selectedBackup.backup.server_id ?? 0,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -172,39 +202,41 @@ export function BackupsTab() {
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: bigint | null): string => {
|
||||
if (!bytes) return 'Unknown size';
|
||||
if (!bytes) return "Unknown size";
|
||||
const b = Number(bytes);
|
||||
if (b === 0) return '0 B';
|
||||
if (b === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(b) / Math.log(k));
|
||||
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | null): string => {
|
||||
if (!date) return 'Unknown date';
|
||||
if (!date) return "Unknown date";
|
||||
return new Date(date).toLocaleString();
|
||||
};
|
||||
|
||||
const getStorageTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'pbs':
|
||||
case "pbs":
|
||||
return <Database className="h-4 w-4" />;
|
||||
case 'local':
|
||||
case "local":
|
||||
return <HardDrive className="h-4 w-4" />;
|
||||
default:
|
||||
return <Server className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => {
|
||||
const getStorageTypeBadgeVariant = (
|
||||
type: string,
|
||||
): "default" | "secondary" | "outline" => {
|
||||
switch (type) {
|
||||
case 'pbs':
|
||||
return 'default';
|
||||
case 'local':
|
||||
return 'secondary';
|
||||
case "pbs":
|
||||
return "default";
|
||||
case "local":
|
||||
return "secondary";
|
||||
default:
|
||||
return 'outline';
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,8 +248,8 @@ export function BackupsTab() {
|
||||
{/* Header with refresh button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground">Backups</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<h2 className="text-foreground text-2xl font-bold">Backups</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Discovered backups grouped by container ID
|
||||
</p>
|
||||
</div>
|
||||
@@ -226,31 +258,38 @@ export function BackupsTab() {
|
||||
disabled={isDiscovering}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} />
|
||||
{isDiscovering ? 'Discovering...' : 'Discover Backups'}
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isDiscovering ? "Discovering..." : "Discover Backups"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{(isLoading || isDiscovering) && backups.length === 0 && (
|
||||
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
|
||||
<div className="bg-card border-border rounded-lg border p-8 text-center">
|
||||
<RefreshCw className="text-muted-foreground mx-auto mb-4 h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground">
|
||||
{isDiscovering ? 'Discovering backups...' : 'Loading backups...'}
|
||||
{isDiscovering ? "Discovering backups..." : "Loading backups..."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isDiscovering && backups.length === 0 && (
|
||||
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
||||
<HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
|
||||
<div className="bg-card border-border rounded-lg border p-8 text-center">
|
||||
<HardDrive className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="text-foreground mb-2 text-lg font-semibold">
|
||||
No backups found
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Click "Discover Backups" to scan for backups on your servers.
|
||||
Click "Discover Backups" to scan for backups on your
|
||||
servers.
|
||||
</p>
|
||||
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Discover Backups
|
||||
</Button>
|
||||
</div>
|
||||
@@ -266,33 +305,35 @@ export function BackupsTab() {
|
||||
return (
|
||||
<div
|
||||
key={container.container_id}
|
||||
className="bg-card rounded-lg border border-border shadow-sm overflow-hidden"
|
||||
className="bg-card border-border overflow-hidden rounded-lg border shadow-sm"
|
||||
>
|
||||
{/* Container header - collapsible */}
|
||||
<button
|
||||
onClick={() => toggleContainer(container.container_id)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors text-left"
|
||||
className="hover:bg-accent/50 flex w-full items-center justify-between p-4 text-left transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<ChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<ChevronRight className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-foreground">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground font-semibold">
|
||||
CT {container.container_id}
|
||||
</span>
|
||||
{container.hostname && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="text-muted-foreground">{container.hostname}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{container.hostname}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{backupCount} {backupCount === 1 ? 'backup' : 'backups'}
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{backupCount} {backupCount === 1 ? "backup" : "backups"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,28 +341,30 @@ export function BackupsTab() {
|
||||
|
||||
{/* Container content - backups list */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="border-border border-t">
|
||||
<div className="space-y-3 p-4">
|
||||
{container.backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="bg-muted/50 rounded-lg p-4 border border-border/50"
|
||||
className="bg-muted/50 border-border/50 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="font-medium text-foreground break-all">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground font-medium break-all">
|
||||
{backup.backup_name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={getStorageTypeBadgeVariant(backup.storage_type)}
|
||||
variant={getStorageTypeBadgeVariant(
|
||||
backup.storage_type,
|
||||
)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{getStorageTypeIcon(backup.storage_type)}
|
||||
{backup.storage_name}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-4 text-sm">
|
||||
{backup.size && (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
@@ -339,7 +382,7 @@ export function BackupsTab() {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<code className="text-xs text-muted-foreground break-all">
|
||||
<code className="text-muted-foreground text-xs break-all">
|
||||
{backup.backup_path}
|
||||
</code>
|
||||
</div>
|
||||
@@ -350,14 +393,19 @@ export function BackupsTab() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-muted/20 hover:bg-muted/30 border border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground transition-all duration-200 hover:scale-105 hover:shadow-md"
|
||||
className="bg-muted/20 hover:bg-muted/30 border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground border transition-all duration-200 hover:scale-105 hover:shadow-md"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 bg-card border-border">
|
||||
<DropdownMenuContent className="bg-card border-border w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleRestoreClick(backup, container.container_id)}
|
||||
onClick={() =>
|
||||
handleRestoreClick(
|
||||
backup,
|
||||
container.container_id,
|
||||
)
|
||||
}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||
>
|
||||
@@ -386,9 +434,9 @@ export function BackupsTab() {
|
||||
|
||||
{/* Error state */}
|
||||
{backupsData && !backupsData.success && (
|
||||
<div className="bg-destructive/10 border border-destructive rounded-lg p-4">
|
||||
<div className="bg-destructive/10 border-destructive rounded-lg border p-4">
|
||||
<p className="text-destructive">
|
||||
Error loading backups: {backupsData.error || 'Unknown error'}
|
||||
Error loading backups: {backupsData.error ?? "Unknown error"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -412,7 +460,8 @@ export function BackupsTab() {
|
||||
)}
|
||||
|
||||
{/* Restore Progress Modal */}
|
||||
{(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
|
||||
{(restoreMutation.isPending ||
|
||||
(restoreSuccess && restoreProgress.length > 0)) && (
|
||||
<LoadingModal
|
||||
isOpen={true}
|
||||
action={currentProgressText}
|
||||
@@ -428,11 +477,13 @@ export function BackupsTab() {
|
||||
|
||||
{/* Restore Success */}
|
||||
{restoreSuccess && (
|
||||
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="bg-success/10 border-success/20 rounded-lg border p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
<span className="font-medium text-success">Restore Completed Successfully</span>
|
||||
<CheckCircle className="text-success h-5 w-5" />
|
||||
<span className="text-success font-medium">
|
||||
Restore Completed Successfully
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -446,7 +497,7 @@ export function BackupsTab() {
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The container has been restored from backup.
|
||||
</p>
|
||||
</div>
|
||||
@@ -454,11 +505,11 @@ export function BackupsTab() {
|
||||
|
||||
{/* Restore Error */}
|
||||
{restoreError && (
|
||||
<div className="bg-error/10 border border-error/20 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="bg-error/10 border-error/20 rounded-lg border p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-error" />
|
||||
<span className="font-medium text-error">Restore Failed</span>
|
||||
<AlertCircle className="text-error h-5 w-5" />
|
||||
<span className="text-error font-medium">Restore Failed</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -472,13 +523,11 @@ export function BackupsTab() {
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{restoreError}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">{restoreError}</p>
|
||||
{restoreProgress.length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
<div className="mt-2 space-y-1">
|
||||
{restoreProgress.map((message, index) => (
|
||||
<p key={index} className="text-sm text-muted-foreground">
|
||||
<p key={index} className="text-muted-foreground text-sm">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
@@ -500,4 +549,3 @@ export function BackupsTab() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,41 +1,53 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { ScriptCard } from './ScriptCard';
|
||||
import { ScriptCardList } from './ScriptCardList';
|
||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||
import { CategorySidebar } from './CategorySidebar';
|
||||
import { FilterBar, type FilterState } from './FilterBar';
|
||||
import { ViewToggle } from './ViewToggle';
|
||||
import { Button } from './ui/button';
|
||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||
import { getDefaultFilters, mergeFiltersWithDefaults } from './filterUtils';
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { ScriptCard } from "./ScriptCard";
|
||||
import { ScriptCardList } from "./ScriptCardList";
|
||||
import { ScriptDetailModal } from "./ScriptDetailModal";
|
||||
import { CategorySidebar } from "./CategorySidebar";
|
||||
import { FilterBar, type FilterState } from "./FilterBar";
|
||||
import { ViewToggle } from "./ViewToggle";
|
||||
import { Button } from "./ui/button";
|
||||
import type { ScriptCard as ScriptCardType } from "~/types/script";
|
||||
import type { Server } from "~/types/server";
|
||||
import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils";
|
||||
|
||||
interface DownloadedScriptsTabProps {
|
||||
onInstallScript?: (
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
mode?: "local" | "ssh",
|
||||
server?: any,
|
||||
server?: Server,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
|
||||
export function DownloadedScriptsTab({
|
||||
onInstallScript,
|
||||
}: DownloadedScriptsTabProps) {
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
||||
const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
|
||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const {
|
||||
data: scriptCardsData,
|
||||
isLoading: githubLoading,
|
||||
error: githubError,
|
||||
refetch,
|
||||
} = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const {
|
||||
data: localScriptsData,
|
||||
isLoading: localLoading,
|
||||
error: localError,
|
||||
} = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||
{ slug: selectedSlug ?? '' },
|
||||
{ enabled: !!selectedSlug }
|
||||
{ slug: selectedSlug ?? "" },
|
||||
{ enabled: !!selectedSlug },
|
||||
);
|
||||
|
||||
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||
@@ -43,7 +55,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Load SAVE_FILTER setting
|
||||
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||
const saveFilterResponse = await fetch("/api/settings/save-filter");
|
||||
let saveFilterEnabled = false;
|
||||
if (saveFilterResponse.ok) {
|
||||
const saveFilterData = await saveFilterResponse.json();
|
||||
@@ -53,9 +65,11 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Load saved filters if SAVE_FILTER is enabled
|
||||
if (saveFilterEnabled) {
|
||||
const filtersResponse = await fetch('/api/settings/filters');
|
||||
const filtersResponse = await fetch("/api/settings/filters");
|
||||
if (filtersResponse.ok) {
|
||||
const filtersData = await filtersResponse.json();
|
||||
const filtersData = (await filtersResponse.json()) as {
|
||||
filters?: Partial<FilterState>;
|
||||
};
|
||||
if (filtersData.filters) {
|
||||
setFilters(mergeFiltersWithDefaults(filtersData.filters));
|
||||
}
|
||||
@@ -63,16 +77,20 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
}
|
||||
|
||||
// Load view mode
|
||||
const viewModeResponse = await fetch('/api/settings/view-mode');
|
||||
const viewModeResponse = await fetch("/api/settings/view-mode");
|
||||
if (viewModeResponse.ok) {
|
||||
const viewModeData = await viewModeResponse.json();
|
||||
const viewMode = viewModeData.viewMode;
|
||||
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
|
||||
if (
|
||||
viewMode &&
|
||||
typeof viewMode === "string" &&
|
||||
(viewMode === "card" || viewMode === "list")
|
||||
) {
|
||||
setViewMode(viewMode);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
console.error("Error loading settings:", error);
|
||||
} finally {
|
||||
setIsLoadingFilters(false);
|
||||
}
|
||||
@@ -87,15 +105,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
const saveFilters = async () => {
|
||||
try {
|
||||
await fetch('/api/settings/filters', {
|
||||
method: 'POST',
|
||||
await fetch("/api/settings/filters", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ filters }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving filters:', error);
|
||||
console.error("Error saving filters:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,15 +128,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
const saveViewMode = async () => {
|
||||
try {
|
||||
await fetch('/api/settings/view-mode', {
|
||||
method: 'POST',
|
||||
await fetch("/api/settings/view-mode", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ viewMode }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving view mode:', error);
|
||||
console.error("Error saving view mode:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -129,31 +147,32 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Extract categories from metadata
|
||||
const categories = React.useMemo((): string[] => {
|
||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||
|
||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories)
|
||||
return [];
|
||||
|
||||
return (scriptCardsData.metadata.categories as any[])
|
||||
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((cat) => cat.name as string)
|
||||
.filter((name): name is string => typeof name === 'string');
|
||||
.filter((name): name is string => typeof name === "string");
|
||||
}, [scriptCardsData]);
|
||||
|
||||
// Get GitHub scripts with download status (deduplicated)
|
||||
const combinedScripts = React.useMemo((): ScriptCardType[] => {
|
||||
if (!scriptCardsData?.success) return [];
|
||||
|
||||
|
||||
// Use Map to deduplicate by slug/name
|
||||
const scriptMap = new Map<string, ScriptCardType>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
|
||||
scriptCardsData.cards?.forEach((script: ScriptCardType) => {
|
||||
if (script?.name && script?.slug) {
|
||||
// Use slug as unique identifier, only keep first occurrence
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
scriptMap.set(script.slug, {
|
||||
...script,
|
||||
source: 'github' as const,
|
||||
source: "github" as const,
|
||||
isDownloaded: false, // Will be updated by status check
|
||||
isUpToDate: false, // Will be updated by status check
|
||||
isUpToDate: false, // Will be updated by status check
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -165,68 +184,77 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
// Update scripts with download status and filter to only downloaded scripts
|
||||
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
|
||||
// Helper to normalize identifiers so underscores vs hyphens don't break matches
|
||||
const normalizeId = (s?: string): string => (s ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\.(sh|bash|py|js|ts)$/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
const normalizeId = (s?: string): string =>
|
||||
(s ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/\.(sh|bash|py|js|ts)$/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return combinedScripts
|
||||
.map(script => {
|
||||
.map((script) => {
|
||||
if (!script?.name) {
|
||||
return script; // Return as-is if invalid
|
||||
}
|
||||
|
||||
|
||||
// Check if there's a corresponding local script
|
||||
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
|
||||
if (!local?.name) return false;
|
||||
|
||||
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
|
||||
if (local.slug && script.slug) {
|
||||
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
|
||||
return true;
|
||||
const hasLocalVersion =
|
||||
localScriptsData?.scripts?.some((local) => {
|
||||
if (!local?.name) return false;
|
||||
|
||||
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
|
||||
if (local.slug && script.slug) {
|
||||
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
||||
// Only use normalized matching for install basenames, not for slug/name matching
|
||||
const normalizedLocal = normalizeId(local.name);
|
||||
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
|
||||
return matchesInstallBasename;
|
||||
}) ?? false;
|
||||
|
||||
|
||||
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
||||
// Only use normalized matching for install basenames, not for slug/name matching
|
||||
const normalizedLocal = normalizeId(local.name);
|
||||
const matchesInstallBasename =
|
||||
(script as any)?.install_basenames?.some(
|
||||
(base: string) => normalizeId(base) === normalizedLocal,
|
||||
) ?? false;
|
||||
return matchesInstallBasename;
|
||||
}) ?? false;
|
||||
|
||||
return {
|
||||
...script,
|
||||
isDownloaded: hasLocalVersion,
|
||||
};
|
||||
})
|
||||
.filter(script => script.isDownloaded); // Only show downloaded scripts
|
||||
.filter((script) => script.isDownloaded); // Only show downloaded scripts
|
||||
}, [combinedScripts, localScriptsData]);
|
||||
|
||||
// Count scripts per category (using downloaded scripts only)
|
||||
const categoryCounts = React.useMemo((): Record<string, number> => {
|
||||
if (!scriptCardsData?.success) return {};
|
||||
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
|
||||
// Initialize all categories with 0
|
||||
categories.forEach((categoryName: string) => {
|
||||
counts[categoryName] = 0;
|
||||
});
|
||||
|
||||
|
||||
// Count each unique downloaded script only once per category
|
||||
downloadedScripts.forEach(script => {
|
||||
downloadedScripts.forEach((script) => {
|
||||
if (script.categoryNames && script.slug) {
|
||||
const countedCategories = new Set<string>();
|
||||
script.categoryNames.forEach((categoryName: unknown) => {
|
||||
if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) {
|
||||
if (
|
||||
typeof categoryName === "string" &&
|
||||
counts[categoryName] !== undefined &&
|
||||
!countedCategories.has(categoryName)
|
||||
) {
|
||||
countedCategories.add(categoryName);
|
||||
counts[categoryName]++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return counts;
|
||||
}, [categories, downloadedScripts, scriptCardsData?.success]);
|
||||
|
||||
@@ -237,15 +265,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
// Filter by search query
|
||||
if (filters.searchQuery?.trim()) {
|
||||
const query = filters.searchQuery.toLowerCase().trim();
|
||||
|
||||
|
||||
if (query.length >= 1) {
|
||||
scripts = scripts.filter(script => {
|
||||
if (!script || typeof script !== 'object') {
|
||||
scripts = scripts.filter((script) => {
|
||||
if (!script || typeof script !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = (script.name ?? '').toLowerCase();
|
||||
const slug = (script.slug ?? '').toLowerCase();
|
||||
const name = (script.name ?? "").toLowerCase();
|
||||
const slug = (script.slug ?? "").toLowerCase();
|
||||
|
||||
return name.includes(query) ?? slug.includes(query);
|
||||
});
|
||||
@@ -254,9 +282,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Filter by category using real category data from downloaded scripts
|
||||
if (selectedCategory) {
|
||||
scripts = scripts.filter(script => {
|
||||
scripts = scripts.filter((script) => {
|
||||
if (!script) return false;
|
||||
|
||||
|
||||
// Check if the downloaded script has categoryNames that include the selected category
|
||||
return script.categoryNames?.includes(selectedCategory) ?? false;
|
||||
});
|
||||
@@ -264,7 +292,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Filter by updateable status
|
||||
if (filters.showUpdatable !== null) {
|
||||
scripts = scripts.filter(script => {
|
||||
scripts = scripts.filter((script) => {
|
||||
if (!script) return false;
|
||||
const isUpdatable = script.updateable ?? false;
|
||||
return filters.showUpdatable ? isUpdatable : !isUpdatable;
|
||||
@@ -273,28 +301,30 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Filter by script types
|
||||
if (filters.selectedTypes.length > 0) {
|
||||
scripts = scripts.filter(script => {
|
||||
scripts = scripts.filter((script) => {
|
||||
if (!script) return false;
|
||||
const scriptType = (script.type ?? '').toLowerCase();
|
||||
|
||||
const scriptType = (script.type ?? "").toLowerCase();
|
||||
|
||||
// Map non-standard types to standard categories
|
||||
const mappedType = scriptType === 'turnkey' ? 'ct' : scriptType;
|
||||
|
||||
return filters.selectedTypes.some(type => type.toLowerCase() === mappedType);
|
||||
const mappedType = scriptType === "turnkey" ? "ct" : scriptType;
|
||||
|
||||
return filters.selectedTypes.some(
|
||||
(type) => type.toLowerCase() === mappedType,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by repositories
|
||||
if (filters.selectedRepositories.length > 0) {
|
||||
scripts = scripts.filter(script => {
|
||||
scripts = scripts.filter((script) => {
|
||||
if (!script) return false;
|
||||
const repoUrl = script.repository_url;
|
||||
|
||||
|
||||
// If script has no repository_url, exclude it when filtering by repositories
|
||||
if (!repoUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Only include scripts from selected repositories
|
||||
return filters.selectedRepositories.includes(repoUrl);
|
||||
});
|
||||
@@ -303,18 +333,18 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
// Apply sorting
|
||||
scripts.sort((a, b) => {
|
||||
if (!a || !b) return 0;
|
||||
|
||||
|
||||
let compareValue = 0;
|
||||
|
||||
|
||||
switch (filters.sortBy) {
|
||||
case 'name':
|
||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||
case "name":
|
||||
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
||||
break;
|
||||
case 'created':
|
||||
case "created":
|
||||
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
|
||||
const aCreated = a?.date_created ?? '';
|
||||
const bCreated = b?.date_created ?? '';
|
||||
|
||||
const aCreated = a?.date_created ?? "";
|
||||
const bCreated = b?.date_created ?? "";
|
||||
|
||||
// If both have dates, compare them directly
|
||||
if (aCreated && bCreated) {
|
||||
// For dates: asc = oldest first (2020 before 2024), desc = newest first (2024 before 2020)
|
||||
@@ -327,15 +357,15 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
compareValue = 1;
|
||||
} else {
|
||||
// Both have no dates, fallback to name comparison
|
||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
|
||||
compareValue = (a.name ?? "").localeCompare(b.name ?? "");
|
||||
}
|
||||
|
||||
|
||||
// Apply sort order
|
||||
return filters.sortOrder === 'asc' ? compareValue : -compareValue;
|
||||
return filters.sortOrder === "asc" ? compareValue : -compareValue;
|
||||
});
|
||||
|
||||
return scripts;
|
||||
@@ -343,8 +373,10 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
// Calculate filter counts for FilterBar
|
||||
const filterCounts = React.useMemo(() => {
|
||||
const updatableCount = downloadedScripts.filter(script => script?.updateable).length;
|
||||
|
||||
const updatableCount = downloadedScripts.filter(
|
||||
(script) => script?.updateable,
|
||||
).length;
|
||||
|
||||
return { installedCount: downloadedScripts.length, updatableCount };
|
||||
}, [downloadedScripts]);
|
||||
|
||||
@@ -362,13 +394,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
useEffect(() => {
|
||||
if (selectedCategory && gridRef.current) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
gridRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
gridRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
inline: "nearest",
|
||||
});
|
||||
}, 100);
|
||||
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [selectedCategory]);
|
||||
@@ -387,22 +419,38 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
if (githubLoading || localLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
Loading downloaded scripts...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (githubError || localError) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-error mb-4">
|
||||
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
<svg
|
||||
className="mx-auto mb-2 h-12 w-12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-lg font-medium">Failed to load downloaded scripts</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
|
||||
<p className="text-lg font-medium">
|
||||
Failed to load downloaded scripts
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{githubError?.message ??
|
||||
localError?.message ??
|
||||
"Unknown error occurred"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -419,14 +467,25 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
if (!downloadedScripts?.length) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<svg
|
||||
className="mx-auto mb-4 h-12 w-12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-lg font-medium">No downloaded scripts found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You haven't downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
You haven't downloaded any scripts yet. Visit the Available
|
||||
Scripts tab to download some scripts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,12 +494,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
|
||||
{/* Category Sidebar */}
|
||||
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||
<div className="order-2 flex-shrink-0 lg:order-1">
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
@@ -451,7 +507,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
||||
<div className="order-1 min-w-0 flex-1 lg:order-2" ref={gridRef}>
|
||||
{/* Enhanced Filter Bar */}
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
@@ -464,26 +520,41 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
/>
|
||||
|
||||
{/* View Toggle */}
|
||||
<ViewToggle
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||
|
||||
{/* Scripts Grid */}
|
||||
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
|
||||
<div className="text-center py-12">
|
||||
{filteredScripts.length === 0 &&
|
||||
(filters.searchQuery ||
|
||||
selectedCategory ||
|
||||
filters.showUpdatable !== null ||
|
||||
filters.selectedTypes.length > 0) ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
<svg
|
||||
className="mx-auto mb-4 h-12 w-12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-lg font-medium">No matching downloaded scripts found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<p className="text-lg font-medium">
|
||||
No matching downloaded scripts found
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Try different filter settings or clear all filters.
|
||||
</p>
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
{filters.searchQuery && (
|
||||
<Button
|
||||
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
|
||||
onClick={() =>
|
||||
handleFiltersChange({ ...filters, searchQuery: "" })
|
||||
}
|
||||
variant="default"
|
||||
size="default"
|
||||
>
|
||||
@@ -502,18 +573,17 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredScripts.map((script, index) => {
|
||||
) : viewMode === "card" ? (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filteredScripts.map((script, index) => {
|
||||
// Add validation to ensure script has required properties
|
||||
if (!script || typeof script !== 'object') {
|
||||
if (!script || typeof script !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||
|
||||
const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
|
||||
|
||||
return (
|
||||
<ScriptCard
|
||||
key={uniqueKey}
|
||||
@@ -522,18 +592,18 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredScripts.map((script, index) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredScripts.map((script, index) => {
|
||||
// Add validation to ensure script has required properties
|
||||
if (!script || typeof script !== 'object') {
|
||||
if (!script || typeof script !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Create a unique key by combining slug, name, and index to handle duplicates
|
||||
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
|
||||
|
||||
const uniqueKey = `${script.slug ?? "unknown"}-${script.name ?? "unnamed"}-${index}`;
|
||||
|
||||
return (
|
||||
<ScriptCardList
|
||||
key={uniqueKey}
|
||||
@@ -542,8 +612,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScriptDetailModal
|
||||
|
||||
@@ -3,7 +3,17 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter, GitBranch } from "lucide-react";
|
||||
import {
|
||||
Package,
|
||||
Monitor,
|
||||
Wrench,
|
||||
Server,
|
||||
FileText,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { getDefaultFilters } from "./filterUtils";
|
||||
|
||||
@@ -49,11 +59,11 @@ export function FilterBar({
|
||||
// Fetch enabled repositories
|
||||
const { data: enabledReposData } = api.repositories.getEnabled.useQuery();
|
||||
const enabledRepos = enabledReposData?.repositories ?? [];
|
||||
|
||||
|
||||
// Helper function to extract repository name from URL
|
||||
const getRepoName = (url: string): string => {
|
||||
try {
|
||||
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
|
||||
if (match) {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
@@ -98,29 +108,33 @@ export function FilterBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
||||
<div className="border-border bg-card mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
|
||||
{/* Loading State */}
|
||||
{isLoadingFilters && (
|
||||
<div className="mb-4 flex items-center justify-center py-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
|
||||
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
|
||||
<span>Loading saved filters...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Filter Header */}
|
||||
{!isLoadingFilters && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
|
||||
<h3 className="text-foreground text-lg font-medium">
|
||||
Filter Scripts
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
|
||||
<ContextualHelpIcon
|
||||
section="available-scripts"
|
||||
tooltip="Help with filtering and searching"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
className="text-muted-foreground hover:text-foreground h-8 w-8"
|
||||
title={isMinimized ? "Expand filters" : "Minimize filters"}
|
||||
>
|
||||
<svg
|
||||
@@ -146,10 +160,10 @@ export function FilterBar({
|
||||
<>
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative max-w-md w-full">
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -167,13 +181,13 @@ export function FilterBar({
|
||||
placeholder="Search scripts..."
|
||||
value={filters.searchQuery}
|
||||
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
|
||||
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
|
||||
className="border-input bg-background text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-primary block w-full rounded-lg border py-3 pr-10 pl-10 text-sm leading-5 focus:ring-2 focus:outline-none"
|
||||
/>
|
||||
{filters.searchQuery && (
|
||||
<Button
|
||||
onClick={() => updateFilters({ searchQuery: "" })}
|
||||
variant="ghost"
|
||||
className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground"
|
||||
className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 flex h-full items-center justify-center pr-3"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
@@ -194,318 +208,335 @@ export function FilterBar({
|
||||
</div>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
||||
{/* Updateable Filter */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next =
|
||||
filters.showUpdatable === null
|
||||
? true
|
||||
: filters.showUpdatable === true
|
||||
? false
|
||||
: null;
|
||||
updateFilters({ showUpdatable: next });
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||
filters.showUpdatable === null
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: filters.showUpdatable === true
|
||||
? "border border-success/20 bg-success/10 text-success"
|
||||
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>{getUpdatableButtonText()}</span>
|
||||
</Button>
|
||||
|
||||
{/* Type Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full flex items-center justify-center space-x-2 ${
|
||||
filters.selectedTypes.length === 0
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: "border border-primary/20 bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{getTypeButtonText()}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{isTypeDropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
|
||||
<div className="p-2">
|
||||
{SCRIPT_TYPES.map((type) => {
|
||||
const IconComponent = type.Icon;
|
||||
return (
|
||||
<label
|
||||
key={type.value}
|
||||
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.selectedTypes.includes(type.value)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
updateFilters({
|
||||
selectedTypes: [
|
||||
...filters.selectedTypes,
|
||||
type.value,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
updateFilters({
|
||||
selectedTypes: filters.selectedTypes.filter(
|
||||
(t) => t !== type.value,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="rounded border-input text-primary focus:ring-primary"
|
||||
/>
|
||||
<IconComponent className="h-4 w-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{type.label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-border p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateFilters({ selectedTypes: [] });
|
||||
setIsTypeDropdownOpen(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
|
||||
{enabledRepos.length > 1 && enabledRepos.map((repo) => {
|
||||
const isSelected = filters.selectedRepositories.includes(repo.url);
|
||||
return (
|
||||
<div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3">
|
||||
{/* Updateable Filter */}
|
||||
<Button
|
||||
key={repo.id}
|
||||
onClick={() => {
|
||||
const currentSelected = filters.selectedRepositories;
|
||||
if (isSelected) {
|
||||
// Remove repository from selection
|
||||
updateFilters({
|
||||
selectedRepositories: currentSelected.filter(url => url !== repo.url)
|
||||
});
|
||||
} else {
|
||||
// Add repository to selection
|
||||
updateFilters({
|
||||
selectedRepositories: [...currentSelected, repo.url]
|
||||
});
|
||||
}
|
||||
const next =
|
||||
filters.showUpdatable === null
|
||||
? true
|
||||
: filters.showUpdatable === true
|
||||
? false
|
||||
: null;
|
||||
updateFilters({ showUpdatable: next });
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||
isSelected
|
||||
? "border border-primary/20 bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
|
||||
filters.showUpdatable === null
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: filters.showUpdatable === true
|
||||
? "border-success/20 bg-success/10 text-success border"
|
||||
: "border-destructive/20 bg-destructive/10 text-destructive border"
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span>{getRepoName(repo.url)}</span>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>{getUpdatableButtonText()}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Sort By Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{filters.sortBy === "name" ? (
|
||||
<FileText className="h-4 w-4" />
|
||||
) : (
|
||||
<Calendar className="h-4 w-4" />
|
||||
)}
|
||||
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{/* Type Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`flex w-full items-center justify-center space-x-2 ${
|
||||
filters.selectedTypes.length === 0
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: "border-primary/20 bg-primary/10 text-primary border"
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{getTypeButtonText()}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{isSortDropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "name" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">By Name</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "created" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">By Created Date</span>
|
||||
</button>
|
||||
</div>
|
||||
{isTypeDropdownOpen && (
|
||||
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border shadow-lg">
|
||||
<div className="p-2">
|
||||
{SCRIPT_TYPES.map((type) => {
|
||||
const IconComponent = type.Icon;
|
||||
return (
|
||||
<label
|
||||
key={type.value}
|
||||
className="hover:bg-accent flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.selectedTypes.includes(type.value)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
updateFilters({
|
||||
selectedTypes: [
|
||||
...filters.selectedTypes,
|
||||
type.value,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
updateFilters({
|
||||
selectedTypes: filters.selectedTypes.filter(
|
||||
(t) => t !== type.value,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="border-input text-primary focus:ring-primary rounded"
|
||||
/>
|
||||
<IconComponent className="h-4 w-4" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{type.label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-border border-t p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateFilters({ selectedTypes: [] });
|
||||
setIsTypeDropdownOpen(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground w-full justify-start"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Order Button */}
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateFilters({
|
||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{filters.sortOrder === "asc" ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 11l5-5m0 0l5 5m-5-5v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 13l-5 5m0 0l-5-5m5 5V6"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
|
||||
{enabledRepos.length > 1 &&
|
||||
enabledRepos.map((repo: { id: number; url: string }) => {
|
||||
const repoUrl = String(repo.url);
|
||||
const isSelected =
|
||||
filters.selectedRepositories.includes(repoUrl);
|
||||
return (
|
||||
<Button
|
||||
key={repo.id}
|
||||
onClick={() => {
|
||||
const currentSelected = filters.selectedRepositories;
|
||||
if (isSelected) {
|
||||
// Remove repository from selection
|
||||
updateFilters({
|
||||
selectedRepositories: currentSelected.filter(
|
||||
(url) => url !== repoUrl,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// Add repository to selection
|
||||
updateFilters({
|
||||
selectedRepositories: [...currentSelected, repoUrl],
|
||||
});
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
|
||||
isSelected
|
||||
? "border-primary/20 bg-primary/10 text-primary border"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span>{getRepoName(repoUrl)}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Filter Summary and Clear All */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{filteredCount === totalScripts ? (
|
||||
<span>Showing all {totalScripts} scripts</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredCount} of {totalScripts} scripts{" "}
|
||||
{hasActiveFilters && (
|
||||
<span className="font-medium text-info">
|
||||
(filtered)
|
||||
{/* Sort By Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||
>
|
||||
{filters.sortBy === "name" ? (
|
||||
<FileText className="h-4 w-4" />
|
||||
) : (
|
||||
<Calendar className="h-4 w-4" />
|
||||
)}
|
||||
<span>
|
||||
{filters.sortBy === "name" ? "By Name" : "By Created Date"}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{isSortDropdownOpen && (
|
||||
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-full rounded-lg border shadow-lg sm:w-48">
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "name" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
|
||||
filters.sortBy === "name"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">By Name</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "created" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
|
||||
filters.sortBy === "created"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">By Created Date</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Order Button */}
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateFilters({
|
||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-1 sm:w-auto"
|
||||
>
|
||||
{filters.sortOrder === "asc" ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 11l5-5m0 0l5 5m-5-5v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 13l-5 5m0 0l-5-5m5 5V6"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Summary and Clear All */}
|
||||
<div className="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{filteredCount === totalScripts ? (
|
||||
<span>Showing all {totalScripts} scripts</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredCount} of {totalScripts} scripts{" "}
|
||||
{hasActiveFilters && (
|
||||
<span className="text-info font-medium">(filtered)</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filter Persistence Status */}
|
||||
{!isLoadingFilters && saveFiltersEnabled && (
|
||||
<div className="text-success flex items-center space-x-1 text-xs">
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Filters are being saved automatically</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-error hover:bg-error/10 hover:text-error-foreground flex w-full items-center justify-center space-x-1 sm:w-auto sm:justify-start"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Clear all filters</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Persistence Status */}
|
||||
{!isLoadingFilters && saveFiltersEnabled && (
|
||||
<div className="flex items-center space-x-1 text-xs text-success">
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Filters are being saved automatically</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-1 text-error hover:bg-error/10 hover:text-error-foreground w-full sm:w-auto justify-center sm:justify-start"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Clear all filters</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, startTransition } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
@@ -159,9 +159,13 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L
|
||||
useEffect(() => {
|
||||
if (configData?.success) {
|
||||
populateFormData(configData);
|
||||
setHasChanges(false);
|
||||
startTransition(() => {
|
||||
setHasChanges(false);
|
||||
});
|
||||
} else if (configData && !configData.success) {
|
||||
setError(String(configData.error ?? 'Failed to load configuration'));
|
||||
startTransition(() => {
|
||||
setError(String(configData.error ?? 'Failed to load configuration'));
|
||||
});
|
||||
}
|
||||
}, [configData]);
|
||||
|
||||
|
||||
@@ -1,34 +1,45 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Loader2, CheckCircle, X } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Loader2, CheckCircle, X } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface LoadingModalProps {
|
||||
isOpen: boolean;
|
||||
action: string;
|
||||
action?: string;
|
||||
logs?: string[];
|
||||
isComplete?: boolean;
|
||||
title?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function LoadingModal({ isOpen, action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) {
|
||||
export function LoadingModal({
|
||||
isOpen,
|
||||
action: _action,
|
||||
logs = [],
|
||||
isComplete = false,
|
||||
title,
|
||||
onClose,
|
||||
}: LoadingModalProps) {
|
||||
// Allow dismissing with ESC only when complete, prevent during running
|
||||
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) });
|
||||
useRegisterModal(isOpen, {
|
||||
id: "loading-modal",
|
||||
allowEscape: isComplete,
|
||||
onClose: onClose ?? (() => null),
|
||||
});
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [logs]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border p-8 max-h-[80vh] flex flex-col relative">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border relative flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border p-8 shadow-xl">
|
||||
{/* Close button - only show when complete */}
|
||||
{isComplete && onClose && (
|
||||
<Button
|
||||
@@ -40,31 +51,30 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
{isComplete ? (
|
||||
<CheckCircle className="h-12 w-12 text-success" />
|
||||
<CheckCircle className="text-success h-12 w-12" />
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||
<Loader2 className="text-primary h-12 w-12 animate-spin" />
|
||||
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Static title text */}
|
||||
{title && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{title && <p className="text-muted-foreground text-sm">{title}</p>}
|
||||
|
||||
{/* Log output */}
|
||||
{logs.length > 0 && (
|
||||
<div className="w-full bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-[60vh] overflow-y-auto terminal-output">
|
||||
<div className="bg-card border-border text-chart-2 terminal-output max-h-[60vh] w-full overflow-y-auto rounded-lg border p-4 font-mono text-xs">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
|
||||
<div
|
||||
key={index}
|
||||
className="mb-1 break-words whitespace-pre-wrap"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
@@ -74,9 +84,15 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
|
||||
|
||||
{!isComplete && (
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="bg-primary h-2 w-2 animate-bounce rounded-full"></div>
|
||||
<div
|
||||
className="bg-primary h-2 w-2 animate-bounce rounded-full"
|
||||
style={{ animationDelay: "0.1s" }}
|
||||
></div>
|
||||
<div
|
||||
className="bg-primary h-2 w-2 animate-bounce rounded-full"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -84,4 +100,3 @@ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, ti
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Lock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { api } from '~/trpc/react';
|
||||
import type { Storage } from '~/server/services/storageService';
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Lock, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { api } from "~/trpc/react";
|
||||
import type { Storage } from "~/server/services/storageService";
|
||||
|
||||
interface PBSCredentialsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -19,76 +19,84 @@ export function PBSCredentialsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
serverId,
|
||||
serverName,
|
||||
storage
|
||||
serverName: _serverName,
|
||||
storage,
|
||||
}: PBSCredentialsModalProps) {
|
||||
const [pbsIp, setPbsIp] = useState('');
|
||||
const [pbsDatastore, setPbsDatastore] = useState('');
|
||||
const [pbsPassword, setPbsPassword] = useState('');
|
||||
const [pbsFingerprint, setPbsFingerprint] = useState('');
|
||||
const [pbsIp, setPbsIp] = useState("");
|
||||
const [pbsDatastore, setPbsDatastore] = useState("");
|
||||
const [pbsPassword, setPbsPassword] = useState("");
|
||||
const [pbsFingerprint, setPbsFingerprint] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
|
||||
// Extract PBS info from storage object
|
||||
const pbsIpFromStorage = (storage as any).server || null;
|
||||
const pbsDatastoreFromStorage = (storage as any).datastore || null;
|
||||
|
||||
const pbsIpFromStorage = (storage as { server?: string }).server ?? null;
|
||||
const pbsDatastoreFromStorage =
|
||||
(storage as { datastore?: string }).datastore ?? null;
|
||||
|
||||
// Fetch existing credentials
|
||||
const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery(
|
||||
{ serverId, storageName: storage.name },
|
||||
{ enabled: isOpen }
|
||||
);
|
||||
|
||||
const { data: credentialData, refetch } =
|
||||
api.pbsCredentials.getCredentialsForStorage.useQuery(
|
||||
{ serverId, storageName: storage.name },
|
||||
{ enabled: isOpen },
|
||||
);
|
||||
|
||||
// Initialize form with storage config values or existing credentials
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (credentialData?.success && credentialData.credential) {
|
||||
// Load existing credentials
|
||||
setPbsIp(credentialData.credential.pbs_ip);
|
||||
setPbsDatastore(credentialData.credential.pbs_datastore);
|
||||
setPbsPassword(''); // Don't show password
|
||||
setPbsFingerprint(credentialData.credential.pbs_fingerprint || '');
|
||||
setPbsIp(String(credentialData.credential.pbs_ip));
|
||||
setPbsDatastore(String(credentialData.credential.pbs_datastore));
|
||||
setPbsPassword(""); // Don't show password
|
||||
setPbsFingerprint(
|
||||
String(credentialData.credential.pbs_fingerprint ?? ""),
|
||||
);
|
||||
} else {
|
||||
// Initialize with storage config values
|
||||
setPbsIp(pbsIpFromStorage || '');
|
||||
setPbsDatastore(pbsDatastoreFromStorage || '');
|
||||
setPbsPassword('');
|
||||
setPbsFingerprint('');
|
||||
setPbsIp(pbsIpFromStorage ?? "");
|
||||
setPbsDatastore(pbsDatastoreFromStorage ?? "");
|
||||
setPbsPassword("");
|
||||
setPbsFingerprint("");
|
||||
}
|
||||
}
|
||||
}, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]);
|
||||
|
||||
|
||||
const saveCredentials = api.pbsCredentials.saveCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetch();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to save PBS credentials:', error);
|
||||
console.error("Failed to save PBS credentials:", error);
|
||||
alert(`Failed to save credentials: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const deleteCredentials = api.pbsCredentials.deleteCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetch();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete PBS credentials:', error);
|
||||
console.error("Failed to delete PBS credentials:", error);
|
||||
alert(`Failed to delete credentials: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
useRegisterModal(isOpen, { id: 'pbs-credentials-modal', allowEscape: true, onClose });
|
||||
|
||||
|
||||
useRegisterModal(isOpen, {
|
||||
id: "pbs-credentials-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!pbsIp || !pbsDatastore || !pbsFingerprint) {
|
||||
alert('Please fill in all required fields (IP, Datastore, Fingerprint)');
|
||||
alert("Please fill in all required fields (IP, Datastore, Fingerprint)");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Password is optional when updating existing credentials
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -104,12 +112,16 @@ export function PBSCredentialsModal({
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete the PBS credentials for this storage?')) {
|
||||
if (
|
||||
!confirm(
|
||||
"Are you sure you want to delete the PBS credentials for this storage?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deleteCredentials.mutateAsync({
|
||||
@@ -120,19 +132,19 @@ export function PBSCredentialsModal({
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
||||
const hasCredentials = credentialData?.success && credentialData.credential;
|
||||
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="border-border flex items-center justify-between border-b p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Lock className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">
|
||||
<Lock className="text-primary h-6 w-6" />
|
||||
<h2 className="text-card-foreground text-2xl font-bold">
|
||||
PBS Credentials - {storage.name}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -142,18 +154,31 @@ export function PBSCredentialsModal({
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Storage Name (read-only) */}
|
||||
<div>
|
||||
<label htmlFor="storage-name" className="block text-sm font-medium text-foreground mb-1">
|
||||
<label
|
||||
htmlFor="storage-name"
|
||||
className="text-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Storage Name
|
||||
</label>
|
||||
<input
|
||||
@@ -161,13 +186,16 @@ export function PBSCredentialsModal({
|
||||
id="storage-name"
|
||||
value={storage.name}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-muted text-muted-foreground border-border cursor-not-allowed"
|
||||
className="bg-muted text-muted-foreground border-border w-full cursor-not-allowed rounded-md border px-3 py-2 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* PBS IP */}
|
||||
<div>
|
||||
<label htmlFor="pbs-ip" className="block text-sm font-medium text-foreground mb-1">
|
||||
<label
|
||||
htmlFor="pbs-ip"
|
||||
className="text-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
PBS Server IP <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -177,17 +205,20 @@ export function PBSCredentialsModal({
|
||||
onChange={(e) => setPbsIp(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||
placeholder="e.g., 10.10.10.226"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
IP address of the Proxmox Backup Server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* PBS Datastore */}
|
||||
<div>
|
||||
<label htmlFor="pbs-datastore" className="block text-sm font-medium text-foreground mb-1">
|
||||
<label
|
||||
htmlFor="pbs-datastore"
|
||||
className="text-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
PBS Datastore <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -197,37 +228,48 @@ export function PBSCredentialsModal({
|
||||
onChange={(e) => setPbsDatastore(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||
placeholder="e.g., NAS03-ISCSI-BACKUP"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Name of the datastore on the PBS server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* PBS Password */}
|
||||
<div>
|
||||
<label htmlFor="pbs-password" className="block text-sm font-medium text-foreground mb-1">
|
||||
Password {!hasCredentials && <span className="text-error">*</span>}
|
||||
<label
|
||||
htmlFor="pbs-password"
|
||||
className="text-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Password{" "}
|
||||
{!hasCredentials && <span className="text-error">*</span>}
|
||||
</label>
|
||||
<input
|
||||
<input
|
||||
type="password"
|
||||
id="pbs-password"
|
||||
value={pbsPassword}
|
||||
onChange={(e) => setPbsPassword(e.target.value)}
|
||||
required={!hasCredentials}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
placeholder={hasCredentials ? "Enter new password (leave empty to keep existing)" : "Enter PBS password"}
|
||||
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||
placeholder={
|
||||
hasCredentials
|
||||
? "Enter new password (leave empty to keep existing)"
|
||||
: "Enter PBS password"
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Password for root@pam user on PBS server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* PBS Fingerprint */}
|
||||
<div>
|
||||
<label htmlFor="pbs-fingerprint" className="block text-sm font-medium text-foreground mb-1">
|
||||
<label
|
||||
htmlFor="pbs-fingerprint"
|
||||
className="text-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Fingerprint <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -237,35 +279,37 @@ export function PBSCredentialsModal({
|
||||
onChange={(e) => setPbsFingerprint(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||
placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button.
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Server fingerprint for auto-acceptance. You can find this on
|
||||
your PBS dashboard by clicking the "Show Fingerprint"
|
||||
button.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Status indicator */}
|
||||
{hasCredentials && (
|
||||
<div className="p-3 bg-success/10 border border-success/20 rounded-lg flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
<span className="text-sm text-success font-medium">
|
||||
<div className="bg-success/10 border-success/20 flex items-center gap-2 rounded-lg border p-3">
|
||||
<CheckCircle className="text-success h-4 w-4" />
|
||||
<span className="text-success text-sm font-medium">
|
||||
Credentials are configured for this storage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4">
|
||||
<div className="flex flex-col justify-end gap-3 pt-4 sm:flex-row">
|
||||
{hasCredentials && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto order-3"
|
||||
className="order-3 w-full sm:w-auto"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
<AlertCircle className="mr-2 h-4 w-4" />
|
||||
Delete Credentials
|
||||
</Button>
|
||||
)}
|
||||
@@ -274,7 +318,7 @@ export function PBSCredentialsModal({
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto order-2"
|
||||
className="order-2 w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -282,9 +326,13 @@ export function PBSCredentialsModal({
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto order-1"
|
||||
className="order-1 w-full sm:w-auto"
|
||||
>
|
||||
{isLoading ? 'Saving...' : hasCredentials ? 'Update Credentials' : 'Save Credentials'}
|
||||
{isLoading
|
||||
? "Saving..."
|
||||
: hasCredentials
|
||||
? "Update Credentials"
|
||||
: "Save Credentials"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -293,4 +341,3 @@ export function PBSCredentialsModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, startTransition } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
@@ -47,7 +47,9 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release
|
||||
// Get current version when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && versionData?.success && versionData.version) {
|
||||
setCurrentVersion(versionData.version);
|
||||
startTransition(() => {
|
||||
setCurrentVersion(versionData.version);
|
||||
});
|
||||
}
|
||||
}, [isOpen, versionData]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
@@ -9,6 +9,10 @@ export function ResyncButton() {
|
||||
const [isResyncing, setIsResyncing] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const hasReloadedRef = useRef<boolean>(false);
|
||||
const isUserInitiatedRef = useRef<boolean>(false);
|
||||
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const resyncMutation = api.scripts.resyncScripts.useMutation({
|
||||
onSuccess: (data) => {
|
||||
@@ -16,29 +20,87 @@ export function ResyncButton() {
|
||||
setLastSync(new Date());
|
||||
if (data.success) {
|
||||
setSyncMessage(data.message ?? 'Scripts synced successfully');
|
||||
// Reload the page after successful sync
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000); // Wait 2 seconds to show the success message
|
||||
// Only reload if this was triggered by user action
|
||||
if (isUserInitiatedRef.current && !hasReloadedRef.current) {
|
||||
hasReloadedRef.current = true;
|
||||
|
||||
// Clear any existing reload timeout
|
||||
if (reloadTimeoutRef.current) {
|
||||
clearTimeout(reloadTimeoutRef.current);
|
||||
reloadTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Set new reload timeout
|
||||
reloadTimeoutRef.current = setTimeout(() => {
|
||||
reloadTimeoutRef.current = null;
|
||||
window.location.reload();
|
||||
}, 2000); // Wait 2 seconds to show the success message
|
||||
} else {
|
||||
// Reset flag if reload didn't happen
|
||||
isUserInitiatedRef.current = false;
|
||||
}
|
||||
} else {
|
||||
setSyncMessage(data.error ?? 'Failed to sync scripts');
|
||||
// Clear message after 3 seconds for errors
|
||||
setTimeout(() => setSyncMessage(null), 3000);
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
setSyncMessage(null);
|
||||
messageTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
isUserInitiatedRef.current = false;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsResyncing(false);
|
||||
setSyncMessage(`Error: ${error.message}`);
|
||||
setTimeout(() => setSyncMessage(null), 3000);
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
setSyncMessage(null);
|
||||
messageTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
isUserInitiatedRef.current = false;
|
||||
},
|
||||
});
|
||||
|
||||
const handleResync = async () => {
|
||||
// Prevent multiple simultaneous sync operations
|
||||
if (isResyncing) return;
|
||||
|
||||
// Clear any pending reload timeout
|
||||
if (reloadTimeoutRef.current) {
|
||||
clearTimeout(reloadTimeoutRef.current);
|
||||
reloadTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Mark as user-initiated before starting
|
||||
isUserInitiatedRef.current = true;
|
||||
hasReloadedRef.current = false;
|
||||
setIsResyncing(true);
|
||||
setSyncMessage(null);
|
||||
resyncMutation.mutate();
|
||||
};
|
||||
|
||||
// Cleanup on unmount - clear any pending timeouts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reloadTimeoutRef.current) {
|
||||
clearTimeout(reloadTimeoutRef.current);
|
||||
reloadTimeoutRef.current = null;
|
||||
}
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
messageTimeoutRef.current = null;
|
||||
}
|
||||
// Reset refs on unmount
|
||||
hasReloadedRef.current = false;
|
||||
isUserInitiatedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import type { ScriptCard } from '~/types/script';
|
||||
import { TypeBadge, UpdateableBadge } from './Badge';
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import type { ScriptCard } from "~/types/script";
|
||||
import { TypeBadge, UpdateableBadge } from "./Badge";
|
||||
|
||||
interface ScriptCardProps {
|
||||
script: ScriptCard;
|
||||
@@ -12,7 +12,12 @@ interface ScriptCardProps {
|
||||
onToggleSelect?: (slug: string) => void;
|
||||
}
|
||||
|
||||
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
|
||||
export function ScriptCard({
|
||||
script,
|
||||
onClick,
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
}: ScriptCardProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const handleImageError = () => {
|
||||
@@ -27,8 +32,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
};
|
||||
|
||||
const getRepoName = (url?: string): string => {
|
||||
if (!url) return '';
|
||||
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||
if (!url) return "";
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
|
||||
if (match) {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
@@ -37,32 +42,36 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
|
||||
className="bg-card border-border hover:border-primary relative flex h-full cursor-pointer flex-col rounded-lg border shadow-md transition-shadow duration-200 hover:shadow-lg"
|
||||
onClick={() => onClick(script)}
|
||||
>
|
||||
{/* Checkbox in top-left corner */}
|
||||
{onToggleSelect && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||
<div
|
||||
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
|
||||
}`}
|
||||
onClick={handleCheckboxClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
|
||||
<div className="flex flex-1 flex-col p-6">
|
||||
{/* Header with logo and name */}
|
||||
<div className="flex items-start space-x-4 mb-4">
|
||||
<div className="mb-4 flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
{script.logo && !imageError ? (
|
||||
<Image
|
||||
@@ -70,42 +79,49 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
alt={`${script.name} logo`}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-lg object-contain"
|
||||
className="h-12 w-12 rounded-lg object-contain"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
|
||||
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<span className="text-muted-foreground text-lg font-semibold">
|
||||
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
{script.name?.charAt(0)?.toUpperCase() || "?"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground truncate">
|
||||
{script.name || 'Unnamed Script'}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-foreground truncate text-lg font-semibold">
|
||||
{script.name || "Unnamed Script"}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Type and Updateable status on first row */}
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-1">
|
||||
<TypeBadge type={script.type ?? 'unknown'} />
|
||||
<div className="flex flex-wrap items-center gap-1 space-x-2">
|
||||
<TypeBadge type={script.type ?? "unknown"} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
{script.repository_url && (
|
||||
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
|
||||
<span
|
||||
className="bg-muted text-muted-foreground border-border rounded border px-2 py-0.5 text-xs"
|
||||
title={script.repository_url}
|
||||
>
|
||||
{getRepoName(script.repository_url)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Download Status */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.isDownloaded ? 'bg-success' : 'bg-error'
|
||||
}`}></div>
|
||||
<span className={`text-xs font-medium ${
|
||||
script.isDownloaded ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
script.isDownloaded ? "bg-success" : "bg-error"
|
||||
}`}
|
||||
></div>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
script.isDownloaded ? "text-success" : "text-error"
|
||||
}`}
|
||||
>
|
||||
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,8 +129,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
|
||||
{script.description || 'No description available'}
|
||||
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
|
||||
{script.description || "No description available"}
|
||||
</p>
|
||||
|
||||
{/* Footer with website link */}
|
||||
@@ -124,12 +140,22 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
href={script.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1"
|
||||
className="text-info hover:text-info/80 flex items-center space-x-1 text-sm font-medium"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Website</span>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import type { ScriptCard } from '~/types/script';
|
||||
import { TypeBadge, UpdateableBadge } from './Badge';
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import type { ScriptCard } from "~/types/script";
|
||||
import { TypeBadge, UpdateableBadge } from "./Badge";
|
||||
|
||||
interface ScriptCardListProps {
|
||||
script: ScriptCard;
|
||||
@@ -12,7 +12,12 @@ interface ScriptCardListProps {
|
||||
onToggleSelect?: (slug: string) => void;
|
||||
}
|
||||
|
||||
export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) {
|
||||
export function ScriptCardList({
|
||||
script,
|
||||
onClick,
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
}: ScriptCardListProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const handleImageError = () => {
|
||||
@@ -27,26 +32,27 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Unknown';
|
||||
if (!dateString) return "Unknown";
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryNames = () => {
|
||||
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
|
||||
return script.categoryNames.join(', ');
|
||||
if (!script.categoryNames || script.categoryNames.length === 0)
|
||||
return "Uncategorized";
|
||||
return script.categoryNames.join(", ");
|
||||
};
|
||||
|
||||
const getRepoName = (url?: string): string => {
|
||||
if (!url) return '';
|
||||
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||
if (!url) return "";
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url);
|
||||
if (match) {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
@@ -55,30 +61,34 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative"
|
||||
className="bg-card border-border hover:border-primary relative cursor-pointer rounded-lg border shadow-sm transition-shadow duration-200 hover:shadow-md"
|
||||
onClick={() => onClick(script)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
{onToggleSelect && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||
<div
|
||||
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
|
||||
}`}
|
||||
onClick={handleCheckboxClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}>
|
||||
|
||||
<div className={`p-6 ${onToggleSelect ? "pl-12" : ""}`}>
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
@@ -88,42 +98,49 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
||||
alt={`${script.name} logo`}
|
||||
width={56}
|
||||
height={56}
|
||||
className="w-14 h-14 rounded-lg object-contain"
|
||||
className="h-14 w-14 rounded-lg object-contain"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-lg">
|
||||
<span className="text-muted-foreground text-lg font-semibold">
|
||||
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
{script.name?.charAt(0)?.toUpperCase() || "?"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Header Row */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
|
||||
{script.name || 'Unnamed Script'}
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-foreground mb-2 truncate text-xl font-semibold">
|
||||
{script.name || "Unnamed Script"}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-3 flex-wrap gap-2">
|
||||
<TypeBadge type={script.type ?? 'unknown'} />
|
||||
<div className="flex flex-wrap items-center gap-2 space-x-3">
|
||||
<TypeBadge type={script.type ?? "unknown"} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
{script.repository_url && (
|
||||
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
|
||||
<span
|
||||
className="bg-muted text-muted-foreground border-border rounded border px-2 py-0.5 text-xs"
|
||||
title={script.repository_url}
|
||||
>
|
||||
{getRepoName(script.repository_url)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.isDownloaded ? 'bg-success' : 'bg-error'
|
||||
}`}></div>
|
||||
<span className={`text-sm font-medium ${
|
||||
script.isDownloaded ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
script.isDownloaded ? "bg-success" : "bg-error"
|
||||
}`}
|
||||
></div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
script.isDownloaded ? "text-success" : "text-error"
|
||||
}`}
|
||||
>
|
||||
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,68 +152,128 @@ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSe
|
||||
href={script.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1 ml-4"
|
||||
className="text-info hover:text-info/80 ml-4 flex items-center space-x-1 text-sm font-medium"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Website</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
|
||||
{script.description || 'No description available'}
|
||||
<p className="text-muted-foreground mb-4 line-clamp-2 text-sm">
|
||||
{script.description || "No description available"}
|
||||
</p>
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center justify-between text-xs">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Categories: {getCategoryNames()}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Created: {formatDate(script.date_created)}</span>
|
||||
</div>
|
||||
{(script.os ?? script.version) && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{script.os && script.version
|
||||
{script.os && script.version
|
||||
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
|
||||
: script.os
|
||||
? script.os.charAt(0).toUpperCase() + script.os.slice(1)
|
||||
: script.os
|
||||
? script.os.charAt(0).toUpperCase() +
|
||||
script.os.slice(1)
|
||||
: script.version
|
||||
? `Version ${script.version}`
|
||||
: ''
|
||||
}
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{script.interface_port && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Port: {script.interface_port}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>ID: {script.slug || 'unknown'}</span>
|
||||
<span>ID: {script.slug || "unknown"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,20 @@ import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { api } from "~/trpc/react";
|
||||
import type { Script } from "~/types/script";
|
||||
import type { Server } from "~/types/server";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { TextViewer } from "./TextViewer";
|
||||
import { ExecutionModeModal } from "./ExecutionModeModal";
|
||||
import { ConfirmationModal } from "./ConfirmationModal";
|
||||
import { ScriptVersionModal } from "./ScriptVersionModal";
|
||||
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
|
||||
import {
|
||||
TypeBadge,
|
||||
UpdateableBadge,
|
||||
PrivilegedBadge,
|
||||
NoteBadge,
|
||||
} from "./Badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
|
||||
interface ScriptDetailModalProps {
|
||||
script: Script | null;
|
||||
@@ -21,7 +27,7 @@ interface ScriptDetailModalProps {
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
mode?: "local" | "ssh",
|
||||
server?: any,
|
||||
server?: Server,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -31,7 +37,11 @@ export function ScriptDetailModal({
|
||||
onClose,
|
||||
onInstallScript,
|
||||
}: ScriptDetailModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'script-detail-modal', allowEscape: true, onClose });
|
||||
useRegisterModal(isOpen, {
|
||||
id: "script-detail-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadMessage, setLoadMessage] = useState<string | null>(null);
|
||||
@@ -40,7 +50,9 @@ export function ScriptDetailModal({
|
||||
const [textViewerOpen, setTextViewerOpen] = useState(false);
|
||||
const [executionModeOpen, setExecutionModeOpen] = useState(false);
|
||||
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
||||
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
|
||||
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
@@ -61,7 +73,11 @@ export function ScriptDetailModal({
|
||||
isLoading: comparisonLoading,
|
||||
} = api.scripts.compareScriptContent.useQuery(
|
||||
{ slug: script?.slug ?? "" },
|
||||
{ enabled: !!script && isOpen },
|
||||
{
|
||||
enabled: !!script && isOpen,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Load script mutation
|
||||
@@ -136,22 +152,27 @@ export function ScriptDetailModal({
|
||||
|
||||
const handleInstallScript = () => {
|
||||
if (!script) return;
|
||||
|
||||
|
||||
// Check if script has multiple variants (default and alpine)
|
||||
const installMethods = script.install_methods || [];
|
||||
const hasMultipleVariants = installMethods.filter(method =>
|
||||
method.type === 'default' || method.type === 'alpine'
|
||||
).length > 1;
|
||||
|
||||
const hasMultipleVariants =
|
||||
installMethods.filter(
|
||||
(method) => method.type === "default" || method.type === "alpine",
|
||||
).length > 1;
|
||||
|
||||
if (hasMultipleVariants) {
|
||||
// Show version selection modal first
|
||||
setVersionModalOpen(true);
|
||||
} else {
|
||||
// Only one variant, proceed directly to execution mode
|
||||
// Use the first available method or default to 'default' type
|
||||
const defaultMethod = installMethods.find(method => method.type === 'default');
|
||||
const defaultMethod = installMethods.find(
|
||||
(method) => method.type === "default",
|
||||
);
|
||||
const firstMethod = installMethods[0];
|
||||
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
|
||||
setSelectedVersionType(
|
||||
defaultMethod?.type ?? firstMethod?.type ?? "default",
|
||||
);
|
||||
setExecutionModeOpen(true);
|
||||
}
|
||||
};
|
||||
@@ -162,17 +183,16 @@ export function ScriptDetailModal({
|
||||
setExecutionModeOpen(true);
|
||||
};
|
||||
|
||||
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
|
||||
const handleExecuteScript = (mode: "local" | "ssh", server?: Server) => {
|
||||
if (!script || !onInstallScript) return;
|
||||
|
||||
// Find the script path based on selected version type
|
||||
const versionType = selectedVersionType || 'default';
|
||||
const scriptMethod = script.install_methods?.find(
|
||||
(method) => method.type === versionType && method.script,
|
||||
) || script.install_methods?.find(
|
||||
(method) => method.script,
|
||||
);
|
||||
|
||||
const versionType = selectedVersionType ?? "default";
|
||||
const scriptMethod =
|
||||
script.install_methods?.find(
|
||||
(method) => method.type === versionType && method.script,
|
||||
) ?? script.install_methods?.find((method) => method.script);
|
||||
|
||||
if (scriptMethod?.script) {
|
||||
const scriptPath = `scripts/${scriptMethod.script}`;
|
||||
const scriptName = script.name;
|
||||
@@ -203,31 +223,31 @@ export function ScriptDetailModal({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
|
||||
<div className="bg-card border-border mx-2 max-h-[95vh] min-h-[80vh] w-full max-w-6xl overflow-y-auto rounded-lg border shadow-xl sm:mx-4 lg:mx-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
|
||||
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
<div className="border-border flex items-center justify-between border-b p-4 sm:p-6">
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-3 sm:space-x-4">
|
||||
{script.logo && !imageError ? (
|
||||
<Image
|
||||
src={script.logo}
|
||||
alt={`${script.name} logo`}
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
|
||||
className="h-12 w-12 flex-shrink-0 rounded-lg object-contain sm:h-16 sm:w-16"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0">
|
||||
<span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
|
||||
<div className="bg-muted flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg sm:h-16 sm:w-16">
|
||||
<span className="text-muted-foreground text-lg font-semibold sm:text-2xl">
|
||||
{script.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
|
||||
<h2 className="text-foreground truncate text-xl font-bold sm:text-2xl">
|
||||
{script.name}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
@@ -239,37 +259,39 @@ export function ScriptDetailModal({
|
||||
href={script.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border hover:bg-accent hover:text-foreground transition-colors"
|
||||
className="bg-muted text-muted-foreground border-border hover:bg-accent hover:text-foreground rounded border px-2 py-0.5 text-xs transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`Source: ${script.repository_url}`}
|
||||
>
|
||||
{script.repository_url.match(/github\.com\/([^\/]+)\/([^\/]+)/)?.[0]?.replace('https://', '') ?? script.repository_url}
|
||||
{/github\.com\/([^\/]+)\/([^\/]+)/
|
||||
.exec(script.repository_url)?.[0]
|
||||
?.replace("https://", "") ?? script.repository_url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Interface Port*/}
|
||||
{script.interface_port && (
|
||||
<div className="ml-3 sm:ml-4 flex-shrink-0">
|
||||
<div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2">
|
||||
<span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2">
|
||||
<div className="ml-3 flex-shrink-0 sm:ml-4">
|
||||
<div className="bg-primary/10 border-primary/30 rounded-lg border px-3 py-1.5 sm:px-4 sm:py-2">
|
||||
<span className="text-muted-foreground mr-2 text-xs font-medium sm:text-sm">
|
||||
Port:
|
||||
</span>
|
||||
<span className="text-sm sm:text-base font-semibold text-foreground font-mono">
|
||||
<span className="text-foreground font-mono text-sm font-semibold sm:text-base">
|
||||
{script.interface_port}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
|
||||
className="text-muted-foreground hover:text-foreground ml-4 flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 sm:h-6 sm:w-6"
|
||||
@@ -288,189 +310,91 @@ export function ScriptDetailModal({
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
|
||||
{/* Install Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
scriptFilesData.ctExists &&
|
||||
onInstallScript && (
|
||||
<Button
|
||||
onClick={handleInstallScript}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
<div className="border-border flex flex-col items-stretch space-y-2 border-b p-4 sm:flex-row sm:items-center sm:space-y-0 sm:space-x-2 sm:p-6">
|
||||
{/* Install Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
scriptFilesData.ctExists &&
|
||||
onInstallScript && (
|
||||
<Button
|
||||
onClick={handleInstallScript}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Install</span>
|
||||
</Button>
|
||||
)}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Install</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* View Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<Button
|
||||
onClick={handleViewScript}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
{/* View Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<Button
|
||||
onClick={handleViewScript}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>View</span>
|
||||
</Button>
|
||||
)}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>View</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Load/Update Script Button */}
|
||||
{(() => {
|
||||
const hasLocalFiles =
|
||||
scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists);
|
||||
const hasDifferences =
|
||||
comparisonData?.success && comparisonData.hasDifferences;
|
||||
const isUpToDate = hasLocalFiles && !hasDifferences;
|
||||
{/* Load/Update Script Button */}
|
||||
{(() => {
|
||||
const hasLocalFiles =
|
||||
scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists);
|
||||
const hasDifferences =
|
||||
comparisonData?.success && comparisonData.hasDifferences;
|
||||
const isUpToDate = hasLocalFiles && !hasDifferences;
|
||||
|
||||
if (!hasLocalFiles) {
|
||||
// No local files - show Load Script button
|
||||
return (
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
isLoading
|
||||
? "cursor-not-allowed bg-muted text-muted-foreground"
|
||||
: "bg-success text-success-foreground hover:bg-success/90"
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Load Script</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
} else if (isUpToDate) {
|
||||
// Local files exist and are up to date - show disabled Update button
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-muted px-4 py-2 font-medium text-muted-foreground transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Up to Date</span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
// Local files exist but have differences - show Update button
|
||||
return (
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
isLoading
|
||||
? "cursor-not-allowed bg-muted text-muted-foreground"
|
||||
: "bg-warning text-warning-foreground hover:bg-warning/90"
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Updating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Update Script</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Delete Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<Button
|
||||
onClick={handleDeleteScript}
|
||||
disabled={isDeleting}
|
||||
variant="destructive"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
if (!hasLocalFiles) {
|
||||
// No local files - show Load Script button
|
||||
return (
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
isLoading
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
: "bg-success text-success-foreground hover:bg-success/90"
|
||||
}`}
|
||||
>
|
||||
{isDeleting ? (
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Deleting...</span>
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -484,23 +408,121 @@ export function ScriptDetailModal({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Delete Script</span>
|
||||
<span>Load Script</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
} else if (isUpToDate) {
|
||||
// Local files exist and are up to date - show disabled Update button
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground flex cursor-not-allowed items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Up to Date</span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
// Local files exist but have differences - show Update button
|
||||
return (
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
isLoading
|
||||
? "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
: "bg-warning text-warning-foreground hover:bg-warning/90"
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Updating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Update Script</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Delete Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<Button
|
||||
onClick={handleDeleteScript}
|
||||
disabled={isDeleting}
|
||||
variant="destructive"
|
||||
size="default"
|
||||
className="flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Deleting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<span>Delete Script</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||
<div className="space-y-4 p-4 sm:space-y-6 sm:p-6">
|
||||
{/* Script Files Status */}
|
||||
{(scriptFilesLoading || comparisonLoading) && (
|
||||
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
<div className="border-primary h-4 w-4 animate-spin rounded-full border-b-2"></div>
|
||||
<span>Loading script status...</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -523,8 +545,8 @@ export function ScriptDetailModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div className="bg-muted text-muted-foreground mb-4 rounded-lg p-3 text-sm">
|
||||
<div className="flex flex-col space-y-2 sm:flex-row sm:items-center sm:space-y-0 sm:space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`}
|
||||
@@ -547,24 +569,67 @@ export function ScriptDetailModal({
|
||||
</div>
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists ||
|
||||
scriptFilesData.installExists) &&
|
||||
comparisonData?.success &&
|
||||
!comparisonLoading && (
|
||||
scriptFilesData.installExists) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
|
||||
></div>
|
||||
<span>
|
||||
Status:{" "}
|
||||
{comparisonData.hasDifferences
|
||||
? "Update available"
|
||||
: "Up to date"}
|
||||
</span>
|
||||
{comparisonData?.success ? (
|
||||
<>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
|
||||
></div>
|
||||
<span>
|
||||
Status:{" "}
|
||||
{comparisonData.hasDifferences
|
||||
? "Update available"
|
||||
: "Up to date"}
|
||||
</span>
|
||||
</>
|
||||
) : comparisonLoading ? (
|
||||
<>
|
||||
<div className="bg-muted h-2 w-2 animate-pulse rounded-full"></div>
|
||||
<span>Checking for updates...</span>
|
||||
</>
|
||||
) : comparisonData?.error ? (
|
||||
<>
|
||||
<div className="bg-destructive h-2 w-2 rounded-full"></div>
|
||||
<span className="text-destructive">
|
||||
Error: {comparisonData.error}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-muted h-2 w-2 rounded-full"></div>
|
||||
<span>Status: Unknown</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void refetchComparison()}
|
||||
disabled={comparisonLoading}
|
||||
className="hover:bg-accent ml-2 flex items-center justify-center rounded-md p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Refresh comparison"
|
||||
>
|
||||
{comparisonLoading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
|
||||
) : (
|
||||
<svg
|
||||
className="text-muted-foreground hover:text-foreground h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{scriptFilesData.files.length > 0 && (
|
||||
<div className="mt-2 text-xs text-muted-foreground break-words">
|
||||
<div className="text-muted-foreground mt-2 text-xs break-words">
|
||||
Files: {scriptFilesData.files.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
@@ -574,17 +639,17 @@ export function ScriptDetailModal({
|
||||
|
||||
{/* Load Message */}
|
||||
{loadMessage && (
|
||||
<div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm">
|
||||
{loadMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
|
||||
<h3 className="text-foreground mb-2 text-base font-semibold sm:text-lg">
|
||||
Description
|
||||
</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm sm:text-base">
|
||||
{script.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -592,50 +657,50 @@ export function ScriptDetailModal({
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
|
||||
Basic Information
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Slug
|
||||
</dt>
|
||||
<dd className="font-mono text-sm text-foreground">
|
||||
<dd className="text-foreground font-mono text-sm">
|
||||
{script.slug}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Date Created
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
<dd className="text-foreground text-sm">
|
||||
{script.date_created}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Categories
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
<dd className="text-foreground text-sm">
|
||||
{script.categories.join(", ")}
|
||||
</dd>
|
||||
</div>
|
||||
{script.interface_port && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Interface Port
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
<dd className="text-foreground text-sm">
|
||||
{script.interface_port}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{script.config_path && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Config Path
|
||||
</dt>
|
||||
<dd className="font-mono text-sm text-foreground">
|
||||
<dd className="text-foreground font-mono text-sm">
|
||||
{script.config_path}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -644,13 +709,13 @@ export function ScriptDetailModal({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
|
||||
Links
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
{script.website && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Website
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
@@ -658,7 +723,7 @@ export function ScriptDetailModal({
|
||||
href={script.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-primary hover:text-primary/80"
|
||||
className="text-primary hover:text-primary/80 break-all"
|
||||
>
|
||||
{script.website}
|
||||
</a>
|
||||
@@ -667,7 +732,7 @@ export function ScriptDetailModal({
|
||||
)}
|
||||
{script.documentation && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Documentation
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
@@ -675,7 +740,7 @@ export function ScriptDetailModal({
|
||||
href={script.documentation}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-primary hover:text-primary/80"
|
||||
className="text-primary hover:text-primary/80 break-all"
|
||||
>
|
||||
{script.documentation}
|
||||
</a>
|
||||
@@ -691,26 +756,26 @@ export function ScriptDetailModal({
|
||||
script.type !== "pve" &&
|
||||
script.type !== "addon" && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
|
||||
Install Methods
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{script.install_methods.map((method, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border border-border bg-card p-3 sm:p-4"
|
||||
className="border-border bg-card rounded-lg border p-3 sm:p-4"
|
||||
>
|
||||
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
|
||||
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
|
||||
<div className="mb-3 flex flex-col justify-between space-y-1 sm:flex-row sm:items-center sm:space-y-0">
|
||||
<h4 className="text-foreground text-sm font-medium capitalize sm:text-base">
|
||||
{method.type}
|
||||
</h4>
|
||||
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
|
||||
<span className="text-muted-foreground font-mono text-xs break-all sm:text-sm">
|
||||
{method.script}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs sm:gap-4 sm:text-sm lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground font-medium">
|
||||
CPU
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
@@ -718,7 +783,7 @@ export function ScriptDetailModal({
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground font-medium">
|
||||
RAM
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
@@ -726,7 +791,7 @@ export function ScriptDetailModal({
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground font-medium">
|
||||
HDD
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
@@ -734,7 +799,7 @@ export function ScriptDetailModal({
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground font-medium">
|
||||
OS
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
@@ -752,26 +817,26 @@ export function ScriptDetailModal({
|
||||
{(script.default_credentials.username ??
|
||||
script.default_credentials.password) && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg">
|
||||
Default Credentials
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
{script.default_credentials.username && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Username
|
||||
</dt>
|
||||
<dd className="font-mono text-sm text-foreground">
|
||||
<dd className="text-foreground font-mono text-sm">
|
||||
{script.default_credentials.username}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{script.default_credentials.password && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
<dt className="text-muted-foreground text-sm font-medium">
|
||||
Password
|
||||
</dt>
|
||||
<dd className="font-mono text-sm text-foreground">
|
||||
<dd className="text-foreground font-mono text-sm">
|
||||
{script.default_credentials.password}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -783,7 +848,7 @@ export function ScriptDetailModal({
|
||||
{/* Notes */}
|
||||
{script.notes.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
||||
<h3 className="text-foreground mb-3 text-lg font-semibold">
|
||||
Notes
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
@@ -798,14 +863,17 @@ export function ScriptDetailModal({
|
||||
key={index}
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
noteType === "warning"
|
||||
? "border-l-4 border-warning bg-warning/10 text-warning"
|
||||
? "border-warning bg-warning/10 text-warning border-l-4"
|
||||
: noteType === "error"
|
||||
? "border-l-4 border-destructive bg-destructive/10 text-destructive"
|
||||
? "border-destructive bg-destructive/10 text-destructive border-l-4"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<NoteBadge noteType={noteType as 'info' | 'warning' | 'error'} className="mr-2 flex-shrink-0">
|
||||
<NoteBadge
|
||||
noteType={noteType as "info" | "warning" | "error"}
|
||||
className="mr-2 flex-shrink-0"
|
||||
>
|
||||
{noteType}
|
||||
</NoteBadge>
|
||||
<span>{noteText}</span>
|
||||
@@ -837,7 +905,13 @@ export function ScriptDetailModal({
|
||||
<TextViewer
|
||||
scriptName={
|
||||
script.install_methods
|
||||
?.find((method) => method.script?.startsWith("ct/"))
|
||||
?.find(
|
||||
(method) =>
|
||||
method.script &&
|
||||
(method.script.startsWith("ct/") ||
|
||||
method.script.startsWith("vm/") ||
|
||||
method.script.startsWith("tools/")),
|
||||
)
|
||||
?.script?.split("/")
|
||||
.pop() ?? `${script.slug}.sh`
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ interface InstalledScript {
|
||||
container_status?: 'running' | 'stopped' | 'unknown';
|
||||
web_ui_ip: string | null;
|
||||
web_ui_port: number | null;
|
||||
is_vm?: boolean;
|
||||
}
|
||||
|
||||
interface ScriptInstallationCardProps {
|
||||
@@ -300,7 +301,7 @@ export function ScriptInstallationCard({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 bg-card border-border">
|
||||
{script.container_id && (
|
||||
{script.container_id && !script.is_vm && (
|
||||
<DropdownMenuItem
|
||||
onClick={onUpdate}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
@@ -318,7 +319,7 @@ export function ScriptInstallationCard({
|
||||
Backup
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
{script.container_id && script.execution_mode === 'ssh' && !script.is_vm && (
|
||||
<DropdownMenuItem
|
||||
onClick={onShell}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Script, ScriptInstallMethod } from '../../types/script';
|
||||
import { Button } from './ui/button';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useState } from "react";
|
||||
import type { Script } from "../../types/script";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
|
||||
interface ScriptVersionModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,16 +12,29 @@ interface ScriptVersionModalProps {
|
||||
script: Script | null;
|
||||
}
|
||||
|
||||
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
|
||||
export function ScriptVersionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectVersion,
|
||||
script,
|
||||
}: ScriptVersionModalProps) {
|
||||
useRegisterModal(isOpen, {
|
||||
id: "script-version-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
// Get available install methods
|
||||
const installMethods = script.install_methods || [];
|
||||
const defaultMethod = installMethods.find(method => method.type === 'default');
|
||||
const alpineMethod = installMethods.find(method => method.type === 'alpine');
|
||||
const defaultMethod = installMethods.find(
|
||||
(method) => method.type === "default",
|
||||
);
|
||||
const alpineMethod = installMethods.find(
|
||||
(method) => method.type === "alpine",
|
||||
);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedVersion) {
|
||||
@@ -35,19 +48,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-2xl rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-bold text-foreground">Select Version</h2>
|
||||
<div className="border-border flex items-center justify-between border-b p-6">
|
||||
<h2 className="text-foreground text-xl font-bold">Select Version</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -55,11 +78,12 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
<h3 className="text-foreground mb-2 text-lg font-medium">
|
||||
Choose a version for "{script.name}"
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the version you want to install. Each version has different resource requirements.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select the version you want to install. Each version has different
|
||||
resource requirements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -67,25 +91,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
{/* Default Version */}
|
||||
{defaultMethod && (
|
||||
<div
|
||||
onClick={() => handleVersionSelect('default')}
|
||||
onClick={() => handleVersionSelect("default")}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||
selectedVersion === 'default'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border bg-card hover:border-primary/50'
|
||||
selectedVersion === "default"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border bg-card hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className="mb-3 flex items-center space-x-3">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedVersion === 'default'
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-border'
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||
selectedVersion === "default"
|
||||
? "border-primary bg-primary"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
{selectedVersion === 'default' && (
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
{selectedVersion === "default" && (
|
||||
<svg
|
||||
className="h-3 w-3 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
@@ -94,27 +122,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground capitalize">
|
||||
<h4 className="text-foreground text-base font-semibold capitalize">
|
||||
{defaultMethod.type}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
|
||||
<div className="ml-8 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU: </span>
|
||||
<span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{defaultMethod.resources.cpu} cores
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">RAM: </span>
|
||||
<span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{defaultMethod.resources.ram} MB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">HDD: </span>
|
||||
<span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{defaultMethod.resources.hdd} GB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">OS: </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{defaultMethod.resources.os} {defaultMethod.resources.version}
|
||||
{defaultMethod.resources.os}{" "}
|
||||
{defaultMethod.resources.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,25 +161,29 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
{/* Alpine Version */}
|
||||
{alpineMethod && (
|
||||
<div
|
||||
onClick={() => handleVersionSelect('alpine')}
|
||||
onClick={() => handleVersionSelect("alpine")}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||
selectedVersion === 'alpine'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border bg-card hover:border-primary/50'
|
||||
selectedVersion === "alpine"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border bg-card hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className="mb-3 flex items-center space-x-3">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedVersion === 'alpine'
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-border'
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||
selectedVersion === "alpine"
|
||||
? "border-primary bg-primary"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
{selectedVersion === 'alpine' && (
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
{selectedVersion === "alpine" && (
|
||||
<svg
|
||||
className="h-3 w-3 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
@@ -153,27 +192,34 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground capitalize">
|
||||
<h4 className="text-foreground text-base font-semibold capitalize">
|
||||
{alpineMethod.type}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
|
||||
<div className="ml-8 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU: </span>
|
||||
<span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{alpineMethod.resources.cpu} cores
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">RAM: </span>
|
||||
<span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{alpineMethod.resources.ram} MB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">HDD: </span>
|
||||
<span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{alpineMethod.resources.hdd} GB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">OS: </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{alpineMethod.resources.os} {alpineMethod.resources.version}
|
||||
{alpineMethod.resources.os}{" "}
|
||||
{alpineMethod.resources.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,12 +230,8 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<Button onClick={onClose} variant="outline" size="default">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -197,7 +239,9 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
disabled={!selectedVersion}
|
||||
variant="default"
|
||||
size="default"
|
||||
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
|
||||
className={
|
||||
!selectedVersion ? "bg-muted-foreground cursor-not-allowed" : ""
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
@@ -207,4 +251,3 @@ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { CreateServerData } from '../../types/server';
|
||||
import { Button } from './ui/button';
|
||||
import { SSHKeyInput } from './SSHKeyInput';
|
||||
import { PublicKeyModal } from './PublicKeyModal';
|
||||
import { Key } from 'lucide-react';
|
||||
import { useState, useEffect } from "react";
|
||||
import type { CreateServerData } from "../../types/server";
|
||||
import { Button } from "./ui/button";
|
||||
import { SSHKeyInput } from "./SSHKeyInput";
|
||||
import { PublicKeyModal } from "./PublicKeyModal";
|
||||
import { Key } from "lucide-react";
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (data: CreateServerData) => void;
|
||||
@@ -14,40 +14,47 @@ interface ServerFormProps {
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel }: ServerFormProps) {
|
||||
export function ServerForm({
|
||||
onSubmit,
|
||||
initialData,
|
||||
isEditing = false,
|
||||
onCancel,
|
||||
}: ServerFormProps) {
|
||||
const [formData, setFormData] = useState<CreateServerData>(
|
||||
initialData ?? {
|
||||
name: '',
|
||||
ip: '',
|
||||
user: '',
|
||||
password: '',
|
||||
auth_type: 'password',
|
||||
ssh_key: '',
|
||||
ssh_key_passphrase: '',
|
||||
name: "",
|
||||
ip: "",
|
||||
user: "",
|
||||
password: "",
|
||||
auth_type: "password",
|
||||
ssh_key: "",
|
||||
ssh_key_passphrase: "",
|
||||
ssh_port: 22,
|
||||
color: '#3b82f6',
|
||||
}
|
||||
color: "#3b82f6",
|
||||
},
|
||||
);
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
|
||||
const [sshKeyError, setSshKeyError] = useState<string>('');
|
||||
const [errors, setErrors] = useState<
|
||||
Partial<Record<keyof CreateServerData, string>>
|
||||
>({});
|
||||
const [sshKeyError, setSshKeyError] = useState<string>("");
|
||||
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
|
||||
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
|
||||
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
|
||||
const [generatedPublicKey, setGeneratedPublicKey] = useState("");
|
||||
const [, setIsGeneratedKey] = useState(false);
|
||||
const [, setGeneratedServerId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadColorCodingSetting = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/color-coding');
|
||||
const response = await fetch("/api/settings/color-coding");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setColorCodingEnabled(Boolean(data.enabled));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading color coding setting:', error);
|
||||
console.error("Error loading color coding setting:", error);
|
||||
}
|
||||
};
|
||||
void loadColorCodingSetting();
|
||||
@@ -58,15 +65,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
if (!trimmed) return false;
|
||||
|
||||
// IPv4 validation
|
||||
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const ipv4Regex =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
if (ipv4Regex.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0)
|
||||
let ipv6Address = trimmed;
|
||||
const zoneIdMatch = trimmed.match(/^(.+)%([a-zA-Z0-9_\-]+)$/);
|
||||
if (zoneIdMatch) {
|
||||
const zoneIdMatch = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed);
|
||||
if (zoneIdMatch?.[1] && zoneIdMatch[2]) {
|
||||
ipv6Address = zoneIdMatch[1];
|
||||
// Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen)
|
||||
const zoneId = zoneIdMatch[2];
|
||||
@@ -79,10 +87,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
// Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc.
|
||||
// Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
|
||||
// Simplified validation: check for valid hex segments separated by colons
|
||||
const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const ipv6Pattern =
|
||||
/^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
if (ipv6Pattern.test(ipv6Address)) {
|
||||
// Additional validation: ensure only one :: compression exists
|
||||
const compressionCount = (ipv6Address.match(/::/g) || []).length;
|
||||
const compressionCount = (ipv6Address.match(/::/g) ?? []).length;
|
||||
if (compressionCount <= 1) {
|
||||
return true;
|
||||
}
|
||||
@@ -91,17 +100,19 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
// FQDN/hostname validation (RFC 1123 compliant)
|
||||
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
|
||||
// Max length 253 characters, each label max 63 characters
|
||||
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
|
||||
const hostnameRegex =
|
||||
/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
|
||||
if (hostnameRegex.test(trimmed) && trimmed.length <= 253) {
|
||||
// Additional check: each label (between dots) must be max 63 chars
|
||||
const labels = trimmed.split('.');
|
||||
if (labels.every(label => label.length > 0 && label.length <= 63)) {
|
||||
const labels = trimmed.split(".");
|
||||
if (labels.every((label) => label.length > 0 && label.length <= 63)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also allow simple hostnames without dots (like 'localhost')
|
||||
const simpleHostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
|
||||
const simpleHostnameRegex =
|
||||
/^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
|
||||
if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
|
||||
return true;
|
||||
}
|
||||
@@ -113,41 +124,44 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Server name is required';
|
||||
newErrors.name = "Server name is required";
|
||||
}
|
||||
|
||||
if (!formData.ip.trim()) {
|
||||
newErrors.ip = 'Server address is required';
|
||||
newErrors.ip = "Server address is required";
|
||||
} else {
|
||||
if (!validateServerAddress(formData.ip)) {
|
||||
newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
|
||||
newErrors.ip =
|
||||
"Please enter a valid IP address (IPv4/IPv6) or hostname";
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.user.trim()) {
|
||||
newErrors.user = 'Username is required';
|
||||
newErrors.user = "Username is required";
|
||||
}
|
||||
|
||||
// Validate SSH port
|
||||
if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
|
||||
newErrors.ssh_port = 'SSH port must be between 1 and 65535';
|
||||
if (
|
||||
formData.ssh_port !== undefined &&
|
||||
(formData.ssh_port < 1 || formData.ssh_port > 65535)
|
||||
) {
|
||||
newErrors.ssh_port = "SSH port must be between 1 and 65535";
|
||||
}
|
||||
|
||||
// Validate authentication based on auth_type
|
||||
const authType = formData.auth_type ?? 'password';
|
||||
|
||||
if (authType === 'password') {
|
||||
const authType = formData.auth_type ?? "password";
|
||||
|
||||
if (authType === "password") {
|
||||
if (!formData.password?.trim()) {
|
||||
newErrors.password = 'Password is required for password authentication';
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'key') {
|
||||
if (!formData.ssh_key?.trim()) {
|
||||
newErrors.ssh_key = 'SSH key is required for key authentication';
|
||||
newErrors.password = "Password is required for password authentication";
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === "key") {
|
||||
if (!formData.ssh_key?.trim()) {
|
||||
newErrors.ssh_key = "SSH key is required for key authentication";
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||
@@ -158,348 +172,411 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
if (validateForm()) {
|
||||
onSubmit(formData);
|
||||
if (!isEditing) {
|
||||
setFormData({
|
||||
name: '',
|
||||
ip: '',
|
||||
user: '',
|
||||
password: '',
|
||||
auth_type: 'password',
|
||||
ssh_key: '',
|
||||
ssh_key_passphrase: '',
|
||||
setFormData({
|
||||
name: "",
|
||||
ip: "",
|
||||
user: "",
|
||||
password: "",
|
||||
auth_type: "password",
|
||||
ssh_key: "",
|
||||
ssh_key_passphrase: "",
|
||||
ssh_port: 22,
|
||||
color: '#3b82f6'
|
||||
color: "#3b82f6",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof CreateServerData) => (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
// Special handling for numeric ssh_port: keep it strictly numeric
|
||||
if (field === 'ssh_port') {
|
||||
const raw = (e.target as HTMLInputElement).value ?? '';
|
||||
const digitsOnly = raw.replace(/\D+/g, '');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
|
||||
}));
|
||||
if (errors.ssh_port) {
|
||||
setErrors(prev => ({ ...prev, ssh_port: undefined }));
|
||||
const handleChange =
|
||||
(field: keyof CreateServerData) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
// Special handling for numeric ssh_port: keep it strictly numeric
|
||||
if (field === "ssh_port") {
|
||||
const raw = (e.target as HTMLInputElement).value ?? "";
|
||||
const digitsOnly = raw.replace(/\D+/g, "");
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
|
||||
}));
|
||||
if (errors.ssh_port) {
|
||||
setErrors((prev) => ({ ...prev, ssh_port: undefined }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({ ...prev, [field]: (e.target as HTMLInputElement).value }));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
|
||||
// Reset generated key state when switching auth types
|
||||
if (field === 'auth_type') {
|
||||
setIsGeneratedKey(false);
|
||||
setGeneratedPublicKey('');
|
||||
}
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: (e.target as HTMLInputElement).value,
|
||||
}));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
|
||||
// Reset generated key state when switching auth types
|
||||
if (field === "auth_type") {
|
||||
setIsGeneratedKey(false);
|
||||
setGeneratedPublicKey("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateKeyPair = async () => {
|
||||
setIsGeneratingKey(true);
|
||||
try {
|
||||
const response = await fetch('/api/servers/generate-keypair', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/api/servers/generate-keypair", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate key pair');
|
||||
throw new Error("Failed to generate key pair");
|
||||
}
|
||||
|
||||
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
|
||||
|
||||
const data = (await response.json()) as {
|
||||
success: boolean;
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
serverId?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (data.success) {
|
||||
const serverId = data.serverId ?? 0;
|
||||
const keyPath = `data/ssh-keys/server_${serverId}_key`;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
ssh_key: data.privateKey ?? '',
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
ssh_key: data.privateKey ?? "",
|
||||
ssh_key_path: keyPath,
|
||||
key_generated: true
|
||||
key_generated: true,
|
||||
}));
|
||||
setGeneratedPublicKey(data.publicKey ?? '');
|
||||
setGeneratedPublicKey(data.publicKey ?? "");
|
||||
setGeneratedServerId(serverId);
|
||||
setIsGeneratedKey(true);
|
||||
setShowPublicKeyModal(true);
|
||||
setSshKeyError('');
|
||||
setSshKeyError("");
|
||||
} else {
|
||||
throw new Error(data.error ?? 'Failed to generate key pair');
|
||||
throw new Error(data.error ?? "Failed to generate key pair");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating key pair:', error);
|
||||
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
|
||||
console.error("Error generating key pair:", error);
|
||||
setSshKeyError(
|
||||
error instanceof Error ? error.message : "Failed to generate key pair",
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSSHKeyChange = (value: string) => {
|
||||
setFormData(prev => ({ ...prev, ssh_key: value }));
|
||||
setFormData((prev) => ({ ...prev, ssh_key: value }));
|
||||
if (errors.ssh_key) {
|
||||
setErrors(prev => ({ ...prev, ssh_key: undefined }));
|
||||
setErrors((prev) => ({ ...prev, ssh_key: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Server Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange('name')}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||
errors.name ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="e.g., Production Server"
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Host/IP Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ip"
|
||||
value={formData.ip}
|
||||
onChange={handleChange('ip')}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||
errors.ip ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0"
|
||||
/>
|
||||
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user"
|
||||
value={formData.user}
|
||||
onChange={handleChange('user')}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||
errors.user ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="e.g., root"
|
||||
/>
|
||||
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
SSH Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="ssh_port"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={formData.ssh_port ?? 22}
|
||||
onChange={handleChange('ssh_port')}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||
errors.ssh_port ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="22"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Authentication Type *
|
||||
</label>
|
||||
<select
|
||||
id="auth_type"
|
||||
value={formData.auth_type ?? 'password'}
|
||||
onChange={handleChange('auth_type')}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
>
|
||||
<option value="password">Password Only</option>
|
||||
<option value="key">SSH Key Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{colorCodingEnabled && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Server Color
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Server Name *
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
id="color"
|
||||
value={formData.color ?? '#3b82f6'}
|
||||
onChange={handleChange('color')}
|
||||
className="w-20 h-10 rounded cursor-pointer border border-border"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Choose a color to identify this server
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange("name")}
|
||||
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
|
||||
errors.name ? "border-destructive" : "border-border"
|
||||
}`}
|
||||
placeholder="e.g., Production Server"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-destructive mt-1 text-sm">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Authentication */}
|
||||
{formData.auth_type === 'password' && (
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={formData.password ?? ''}
|
||||
onChange={handleChange('password')}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||
errors.password ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Key Authentication */}
|
||||
{formData.auth_type === 'key' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-muted-foreground">
|
||||
SSH Private Key *
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateKeyPair}
|
||||
disabled={isGeneratingKey}
|
||||
className="gap-2"
|
||||
<label
|
||||
htmlFor="ip"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Host/IP Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ip"
|
||||
value={formData.ip}
|
||||
onChange={handleChange("ip")}
|
||||
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
|
||||
errors.ip ? "border-destructive" : "border-border"
|
||||
}`}
|
||||
placeholder="e.g., 192.168.1.100, server.example.com, 2001:db8::1, or fe80::...%eth0"
|
||||
/>
|
||||
{errors.ip && (
|
||||
<p className="text-destructive mt-1 text-sm">{errors.ip}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="user"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user"
|
||||
value={formData.user}
|
||||
onChange={handleChange("user")}
|
||||
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
|
||||
errors.user ? "border-destructive" : "border-border"
|
||||
}`}
|
||||
placeholder="e.g., root"
|
||||
/>
|
||||
{errors.user && (
|
||||
<p className="text-destructive mt-1 text-sm">{errors.user}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ssh_port"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
SSH Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="ssh_port"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={formData.ssh_port ?? 22}
|
||||
onChange={handleChange("ssh_port")}
|
||||
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
|
||||
errors.ssh_port ? "border-destructive" : "border-border"
|
||||
}`}
|
||||
placeholder="22"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
{errors.ssh_port && (
|
||||
<p className="text-destructive mt-1 text-sm">{errors.ssh_port}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="auth_type"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Authentication Type *
|
||||
</label>
|
||||
<select
|
||||
id="auth_type"
|
||||
value={formData.auth_type ?? "password"}
|
||||
onChange={handleChange("auth_type")}
|
||||
className="bg-card text-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||
>
|
||||
<option value="password">Password Only</option>
|
||||
<option value="key">SSH Key Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{colorCodingEnabled && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="color"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Show manual key input only if no key has been generated */}
|
||||
{!formData.key_generated && (
|
||||
<>
|
||||
<SSHKeyInput
|
||||
value={formData.ssh_key ?? ''}
|
||||
onChange={handleSSHKeyChange}
|
||||
onError={setSshKeyError}
|
||||
Server Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
id="color"
|
||||
value={formData.color ?? "#3b82f6"}
|
||||
onChange={handleChange("color")}
|
||||
className="border-border h-10 w-20 cursor-pointer rounded border"
|
||||
/>
|
||||
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
||||
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show generated key status */}
|
||||
{formData.key_generated && (
|
||||
<div className="p-3 bg-success/10 border border-success/20 rounded-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-success-foreground">
|
||||
SSH key pair generated successfully
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPublicKeyModal(true)}
|
||||
className="gap-2 border-info/20 text-info bg-info/10 hover:bg-info/20"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
View Public Key
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-success/80 mt-1">
|
||||
The private key has been generated and will be saved with the server.
|
||||
</p>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Choose a color to identify this server
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Authentication */}
|
||||
{formData.auth_type === "password" && (
|
||||
<div>
|
||||
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
SSH Key Passphrase (Optional)
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="ssh_key_passphrase"
|
||||
value={formData.ssh_key_passphrase ?? ''}
|
||||
onChange={handleChange('ssh_key_passphrase')}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
placeholder="Enter passphrase for encrypted key"
|
||||
id="password"
|
||||
value={formData.password ?? ""}
|
||||
onChange={handleChange("password")}
|
||||
className={`bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none ${
|
||||
errors.password ? "border-destructive" : "border-border"
|
||||
}`}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Only required if your SSH key is encrypted with a passphrase
|
||||
</p>
|
||||
{errors.password && (
|
||||
<p className="text-destructive mt-1 text-sm">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
||||
{isEditing && onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto order-2 sm:order-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto order-1 sm:order-2"
|
||||
>
|
||||
{isEditing ? 'Update Server' : 'Add Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Public Key Modal */}
|
||||
<PublicKeyModal
|
||||
isOpen={showPublicKeyModal}
|
||||
onClose={() => setShowPublicKeyModal(false)}
|
||||
publicKey={generatedPublicKey}
|
||||
serverName={formData.name || 'New Server'}
|
||||
serverIp={formData.ip}
|
||||
/>
|
||||
|
||||
{/* SSH Key Authentication */}
|
||||
{formData.auth_type === "key" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label className="text-muted-foreground block text-sm font-medium">
|
||||
SSH Private Key *
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateKeyPair}
|
||||
disabled={isGeneratingKey}
|
||||
className="gap-2"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
{isGeneratingKey ? "Generating..." : "Generate Key Pair"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Show manual key input only if no key has been generated */}
|
||||
{!formData.key_generated && (
|
||||
<>
|
||||
<SSHKeyInput
|
||||
value={formData.ssh_key ?? ""}
|
||||
onChange={handleSSHKeyChange}
|
||||
onError={setSshKeyError}
|
||||
/>
|
||||
{errors.ssh_key && (
|
||||
<p className="text-destructive mt-1 text-sm">
|
||||
{errors.ssh_key}
|
||||
</p>
|
||||
)}
|
||||
{sshKeyError && (
|
||||
<p className="text-destructive mt-1 text-sm">
|
||||
{sshKeyError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show generated key status */}
|
||||
{formData.key_generated && (
|
||||
<div className="bg-success/10 border-success/20 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="text-success h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-success-foreground text-sm font-medium">
|
||||
SSH key pair generated successfully
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPublicKeyModal(true)}
|
||||
className="border-info/20 text-info bg-info/10 hover:bg-info/20 gap-2"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
View Public Key
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-success/80 mt-1 text-xs">
|
||||
The private key has been generated and will be saved with
|
||||
the server.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ssh_key_passphrase"
|
||||
className="text-muted-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
SSH Key Passphrase (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="ssh_key_passphrase"
|
||||
value={formData.ssh_key_passphrase ?? ""}
|
||||
onChange={handleChange("ssh_key_passphrase")}
|
||||
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||
placeholder="Enter passphrase for encrypted key"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Only required if your SSH key is encrypted with a passphrase
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col justify-end space-y-2 pt-4 sm:flex-row sm:space-y-0 sm:space-x-3">
|
||||
{isEditing && onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="order-2 w-full sm:order-1 sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="default"
|
||||
className="order-1 w-full sm:order-2 sm:w-auto"
|
||||
>
|
||||
{isEditing ? "Update Server" : "Add Server"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Public Key Modal */}
|
||||
<PublicKeyModal
|
||||
isOpen={showPublicKeyModal}
|
||||
onClose={() => setShowPublicKeyModal(false)}
|
||||
publicKey={generatedPublicKey}
|
||||
serverName={formData.name || "New Server"}
|
||||
serverIp={formData.ip}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Database, RefreshCw, CheckCircle, Lock, AlertCircle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { api } from '~/trpc/react';
|
||||
import { PBSCredentialsModal } from './PBSCredentialsModal';
|
||||
import type { Storage } from '~/server/services/storageService';
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Database,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
Lock,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PBSCredentialsModal } from "./PBSCredentialsModal";
|
||||
import type { Storage } from "~/server/services/storageService";
|
||||
|
||||
interface ServerStoragesModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -19,30 +25,38 @@ export function ServerStoragesModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
serverId,
|
||||
serverName
|
||||
serverName,
|
||||
}: ServerStoragesModalProps) {
|
||||
const [forceRefresh, setForceRefresh] = useState(false);
|
||||
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(null);
|
||||
|
||||
const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery(
|
||||
{ serverId, forceRefresh },
|
||||
{ enabled: isOpen }
|
||||
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
|
||||
const { data, isLoading, refetch } =
|
||||
api.installedScripts.getBackupStorages.useQuery(
|
||||
{ serverId, forceRefresh },
|
||||
{ enabled: isOpen },
|
||||
);
|
||||
|
||||
// Fetch all PBS credentials for this server to show status indicators
|
||||
const { data: allCredentials } = api.pbsCredentials.getAllCredentialsForServer.useQuery(
|
||||
{ serverId },
|
||||
{ enabled: isOpen }
|
||||
);
|
||||
|
||||
const { data: allCredentials } =
|
||||
api.pbsCredentials.getAllCredentialsForServer.useQuery(
|
||||
{ serverId },
|
||||
{ enabled: isOpen },
|
||||
);
|
||||
|
||||
const credentialsMap = new Map<string, boolean>();
|
||||
if (allCredentials?.success) {
|
||||
allCredentials.credentials.forEach(c => {
|
||||
credentialsMap.set(c.storage_name, true);
|
||||
allCredentials.credentials.forEach((c: { storage_name: string }) => {
|
||||
credentialsMap.set(String(c.storage_name), true);
|
||||
});
|
||||
}
|
||||
|
||||
useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose });
|
||||
useRegisterModal(isOpen, {
|
||||
id: "server-storages-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const handleRefresh = () => {
|
||||
setForceRefresh(true);
|
||||
@@ -53,16 +67,16 @@ export function ServerStoragesModal({
|
||||
if (!isOpen) return null;
|
||||
|
||||
const storages = data?.success ? data.storages : [];
|
||||
const backupStorages = storages.filter(s => s.supportsBackup);
|
||||
const backupStorages = storages.filter((s) => s.supportsBackup);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-3xl flex-col rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="border-border flex items-center justify-between border-b p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">
|
||||
<Database className="text-primary h-6 w-6" />
|
||||
<h2 className="text-card-foreground text-2xl font-bold">
|
||||
Storages for {serverName}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -73,7 +87,9 @@ export function ServerStoragesModal({
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
@@ -82,8 +98,18 @@ export function ServerStoragesModal({
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -92,96 +118,112 @@ export function ServerStoragesModal({
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
||||
<div className="py-8 text-center">
|
||||
<div className="border-primary mb-4 inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||
<p className="text-muted-foreground">Loading storages...</p>
|
||||
</div>
|
||||
) : !data?.success ? (
|
||||
<div className="text-center py-8">
|
||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<div className="py-8 text-center">
|
||||
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<p className="text-foreground mb-2">Failed to load storages</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{data?.error ?? 'Unknown error occurred'}
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{data?.error ?? "Unknown error occurred"}
|
||||
</p>
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : storages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<div className="py-8 text-center">
|
||||
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<p className="text-foreground mb-2">No storages found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Make sure your server has storages configured.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data.cached && (
|
||||
<div className="mb-4 p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
|
||||
Showing cached data. Click Refresh to fetch latest from server.
|
||||
<div className="bg-muted/50 text-muted-foreground mb-4 rounded-lg p-3 text-sm">
|
||||
Showing cached data. Click Refresh to fetch latest from
|
||||
server.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
{storages.map((storage) => {
|
||||
const isBackupCapable = storage.supportsBackup;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={storage.name}
|
||||
className={`p-4 border rounded-lg ${
|
||||
className={`rounded-lg border p-4 ${
|
||||
isBackupCapable
|
||||
? 'border-success/50 bg-success/5'
|
||||
: 'border-border bg-card'
|
||||
? "border-success/50 bg-success/5"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-foreground font-medium">
|
||||
{storage.name}
|
||||
</h3>
|
||||
{isBackupCapable && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
|
||||
<span className="bg-success/20 text-success border-success/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Backup
|
||||
</span>
|
||||
)}
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
||||
<span className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs font-medium">
|
||||
{storage.type}
|
||||
</span>
|
||||
{storage.type === 'pbs' && (
|
||||
credentialsMap.has(storage.name) ? (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
|
||||
{storage.type === "pbs" &&
|
||||
(credentialsMap.has(storage.name) ? (
|
||||
<span className="bg-success/20 text-success border-success/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Credentials Configured
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-warning/20 text-warning border border-warning/30 flex items-center gap-1">
|
||||
<span className="bg-warning/20 text-warning border-warning/30 flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Credentials Needed
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div className="text-muted-foreground space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Content:</span> {storage.content.join(', ')}
|
||||
<span className="font-medium">Content:</span>{" "}
|
||||
{storage.content.join(", ")}
|
||||
</div>
|
||||
{storage.nodes && storage.nodes.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium">Nodes:</span> {storage.nodes.join(', ')}
|
||||
<span className="font-medium">Nodes:</span>{" "}
|
||||
{storage.nodes.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(storage)
|
||||
.filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
|
||||
.filter(
|
||||
([key]) =>
|
||||
![
|
||||
"name",
|
||||
"type",
|
||||
"content",
|
||||
"supportsBackup",
|
||||
"nodes",
|
||||
].includes(key),
|
||||
)
|
||||
.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span> {String(value)}
|
||||
<span className="font-medium capitalize">
|
||||
{key.replace(/_/g, " ")}:
|
||||
</span>{" "}
|
||||
{String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{storage.type === 'pbs' && (
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
{storage.type === "pbs" && (
|
||||
<div className="border-border mt-3 border-t pt-3">
|
||||
<Button
|
||||
onClick={() => setSelectedPBSStorage(storage)}
|
||||
variant="outline"
|
||||
@@ -189,7 +231,10 @@ export function ServerStoragesModal({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
{credentialsMap.has(storage.name) ? 'Edit' : 'Configure'} Credentials
|
||||
{credentialsMap.has(storage.name)
|
||||
? "Edit"
|
||||
: "Configure"}{" "}
|
||||
Credentials
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -198,11 +243,13 @@ export function ServerStoragesModal({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
{backupStorages.length > 0 && (
|
||||
<div className="mt-6 p-4 bg-success/10 border border-success/20 rounded-lg">
|
||||
<p className="text-sm text-success font-medium">
|
||||
{backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups
|
||||
<div className="bg-success/10 border-success/20 mt-6 rounded-lg border p-4">
|
||||
<p className="text-success text-sm font-medium">
|
||||
{backupStorages.length} storage
|
||||
{backupStorages.length !== 1 ? "s" : ""} available for
|
||||
backups
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -210,7 +257,7 @@ export function ServerStoragesModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* PBS Credentials Modal */}
|
||||
{selectedPBSStorage && (
|
||||
<PBSCredentialsModal
|
||||
@@ -224,4 +271,3 @@ export function ServerStoragesModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]);
|
||||
|
||||
const startScript = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Button } from './ui/button';
|
||||
import type { Script } from '../../types/script';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { Button } from "./ui/button";
|
||||
import type { Script } from "../../types/script";
|
||||
|
||||
interface TextViewerProps {
|
||||
scriptName: string;
|
||||
@@ -14,154 +14,161 @@ interface TextViewerProps {
|
||||
}
|
||||
|
||||
interface ScriptContent {
|
||||
ctScript?: string;
|
||||
mainScript?: string;
|
||||
installScript?: string;
|
||||
alpineCtScript?: string;
|
||||
alpineMainScript?: string;
|
||||
alpineInstallScript?: string;
|
||||
}
|
||||
|
||||
export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
|
||||
export function TextViewer({
|
||||
scriptName,
|
||||
isOpen,
|
||||
onClose,
|
||||
script,
|
||||
}: TextViewerProps) {
|
||||
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
|
||||
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
|
||||
const [activeTab, setActiveTab] = useState<"main" | "install">("main");
|
||||
const [selectedVersion, setSelectedVersion] = useState<"default" | "alpine">(
|
||||
"default",
|
||||
);
|
||||
|
||||
// Extract slug from script name (remove .sh extension)
|
||||
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
|
||||
|
||||
// Check if alpine variant exists
|
||||
const hasAlpineVariant = script?.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
const slug = scriptName.replace(/\.sh$/, "").replace(/^alpine-/, "");
|
||||
|
||||
// Get default and alpine install methods
|
||||
const defaultMethod = script?.install_methods?.find(
|
||||
(method) => method.type === "default",
|
||||
);
|
||||
|
||||
// Get script names for default and alpine versions
|
||||
const defaultScriptName = scriptName.replace(/^alpine-/, '');
|
||||
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
|
||||
const alpineMethod = script?.install_methods?.find(
|
||||
(method) => method.type === "alpine",
|
||||
);
|
||||
|
||||
// Check if alpine variant exists
|
||||
const hasAlpineVariant = !!alpineMethod;
|
||||
|
||||
// Get script paths from install_methods
|
||||
const defaultScriptPath = defaultMethod?.script;
|
||||
const alpineScriptPath = alpineMethod?.script;
|
||||
|
||||
// Determine if install scripts exist (only for ct/ scripts typically)
|
||||
const hasInstallScript =
|
||||
defaultScriptPath?.startsWith("ct/") ?? alpineScriptPath?.startsWith("ct/");
|
||||
|
||||
// Get script names for display
|
||||
const defaultScriptName = scriptName.replace(/^alpine-/, "");
|
||||
|
||||
const loadScriptContent = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
// Build fetch requests for default version
|
||||
// Build fetch requests based on actual script paths from install_methods
|
||||
const requests: Promise<Response>[] = [];
|
||||
|
||||
// Default CT script
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
|
||||
// Tools, VM, VW scripts
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
|
||||
// Default install script
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
|
||||
);
|
||||
|
||||
// Alpine versions if variant exists
|
||||
if (hasAlpineVariant) {
|
||||
const requestTypes: Array<
|
||||
"default-main" | "default-install" | "alpine-main" | "alpine-install"
|
||||
> = [];
|
||||
|
||||
// Default main script (ct/, vm/, tools/, etc.)
|
||||
if (defaultScriptPath) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
|
||||
fetch(
|
||||
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`,
|
||||
),
|
||||
);
|
||||
requestTypes.push("default-main");
|
||||
}
|
||||
|
||||
|
||||
// Default install script (only for ct/ scripts)
|
||||
if (hasInstallScript && defaultScriptPath?.startsWith("ct/")) {
|
||||
requests.push(
|
||||
fetch(
|
||||
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`,
|
||||
),
|
||||
);
|
||||
requestTypes.push("default-install");
|
||||
}
|
||||
|
||||
// Alpine main script
|
||||
if (hasAlpineVariant && alpineScriptPath) {
|
||||
requests.push(
|
||||
fetch(
|
||||
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`,
|
||||
),
|
||||
);
|
||||
requestTypes.push("alpine-main");
|
||||
}
|
||||
|
||||
// Alpine install script (only for ct/ scripts)
|
||||
if (
|
||||
hasAlpineVariant &&
|
||||
hasInstallScript &&
|
||||
alpineScriptPath?.startsWith("ct/")
|
||||
) {
|
||||
requests.push(
|
||||
fetch(
|
||||
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`,
|
||||
),
|
||||
);
|
||||
requestTypes.push("alpine-install");
|
||||
}
|
||||
|
||||
const responses = await Promise.allSettled(requests);
|
||||
|
||||
const content: ScriptContent = {};
|
||||
let responseIndex = 0;
|
||||
|
||||
// Default CT script
|
||||
const ctResponse = responses[responseIndex];
|
||||
if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
|
||||
const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (ctData.result?.data?.json?.success) {
|
||||
content.ctScript = ctData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// Tools script
|
||||
const toolsResponse = responses[responseIndex];
|
||||
if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) {
|
||||
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (toolsData.result?.data?.json?.success) {
|
||||
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// VM script
|
||||
const vmResponse = responses[responseIndex];
|
||||
if (vmResponse?.status === 'fulfilled' && vmResponse.value.ok) {
|
||||
const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vmData.result?.data?.json?.success) {
|
||||
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// VW script
|
||||
const vwResponse = responses[responseIndex];
|
||||
if (vwResponse?.status === 'fulfilled' && vwResponse.value.ok) {
|
||||
const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vwData.result?.data?.json?.success) {
|
||||
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// Default install script
|
||||
const installResponse = responses[responseIndex];
|
||||
if (installResponse?.status === 'fulfilled' && installResponse.value.ok) {
|
||||
const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (installData.result?.data?.json?.success) {
|
||||
content.installScript = installData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
responseIndex++;
|
||||
// Alpine CT script
|
||||
if (hasAlpineVariant) {
|
||||
const alpineCtResponse = responses[responseIndex];
|
||||
if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) {
|
||||
const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (alpineCtData.result?.data?.json?.success) {
|
||||
content.alpineCtScript = alpineCtData.result.data.json.content;
|
||||
// Process responses based on their types
|
||||
await Promise.all(
|
||||
responses.map(async (response, index) => {
|
||||
if (response.status === "fulfilled" && response.value.ok) {
|
||||
try {
|
||||
const data = (await response.value.json()) as {
|
||||
result?: {
|
||||
data?: { json?: { success?: boolean; content?: string } };
|
||||
};
|
||||
};
|
||||
const type = requestTypes[index];
|
||||
if (
|
||||
data.result?.data?.json?.success &&
|
||||
data.result.data.json.content
|
||||
) {
|
||||
switch (type) {
|
||||
case "default-main":
|
||||
content.mainScript = data.result.data.json.content;
|
||||
break;
|
||||
case "default-install":
|
||||
content.installScript = data.result.data.json.content;
|
||||
break;
|
||||
case "alpine-main":
|
||||
content.alpineMainScript = data.result.data.json.content;
|
||||
break;
|
||||
case "alpine-install":
|
||||
content.alpineInstallScript = data.result.data.json.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
responseIndex++;
|
||||
}
|
||||
|
||||
// Alpine install script
|
||||
if (hasAlpineVariant) {
|
||||
const alpineInstallResponse = responses[responseIndex];
|
||||
if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) {
|
||||
const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (alpineInstallData.result?.data?.json?.success) {
|
||||
content.alpineInstallScript = alpineInstallData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setScriptContent(content);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load script content');
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load script content",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
|
||||
}, [
|
||||
defaultScriptPath,
|
||||
alpineScriptPath,
|
||||
slug,
|
||||
hasAlpineVariant,
|
||||
hasInstallScript,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && scriptName) {
|
||||
@@ -179,51 +186,63 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
|
||||
<div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-6xl flex-col rounded-lg border shadow-xl sm:mx-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
<div className="border-border flex items-center justify-between border-b p-6">
|
||||
<div className="flex flex-1 items-center space-x-4">
|
||||
<h2 className="text-foreground text-2xl font-bold">
|
||||
Script Viewer: {defaultScriptName}
|
||||
</h2>
|
||||
{hasAlpineVariant && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={selectedVersion === 'default' ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedVersion('default')}
|
||||
variant={
|
||||
selectedVersion === "default" ? "default" : "outline"
|
||||
}
|
||||
onClick={() => setSelectedVersion("default")}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Default
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedVersion === 'alpine' ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedVersion('alpine')}
|
||||
variant={selectedVersion === "alpine" ? "default" : "outline"}
|
||||
onClick={() => setSelectedVersion("alpine")}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Alpine
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
|
||||
(selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
|
||||
{/* Boolean logic intentionally uses || for truthiness checks - eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
|
||||
{((selectedVersion === "default" &&
|
||||
Boolean(
|
||||
scriptContent.mainScript ?? scriptContent.installScript,
|
||||
)) ||
|
||||
(selectedVersion === "alpine" &&
|
||||
Boolean(
|
||||
scriptContent.alpineMainScript ??
|
||||
scriptContent.alpineInstallScript,
|
||||
))) && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('ct')}
|
||||
variant={activeTab === "main" ? "outline" : "ghost"}
|
||||
onClick={() => setActiveTab("main")}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
CT Script
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'install' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('install')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Install Script
|
||||
Script
|
||||
</Button>
|
||||
{hasInstallScript && (
|
||||
<Button
|
||||
variant={activeTab === "install" ? "outline" : "ghost"}
|
||||
onClick={() => setActiveTab("install")}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Install Script
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -231,92 +250,108 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">Loading script content...</div>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-lg">
|
||||
Loading script content...
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-destructive">Error: {error}</div>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-destructive text-lg">Error: {error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === 'ct' && (
|
||||
selectedVersion === 'default' && scriptContent.ctScript ? (
|
||||
{activeTab === "main" &&
|
||||
(selectedVersion === "default" && scriptContent.mainScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
padding: "1rem",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.ctScript}
|
||||
{scriptContent.mainScript}
|
||||
</SyntaxHighlighter>
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
|
||||
) : selectedVersion === "alpine" &&
|
||||
scriptContent.alpineMainScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
padding: "1rem",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.alpineCtScript}
|
||||
{scriptContent.alpineMainScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-lg">
|
||||
{selectedVersion === "default"
|
||||
? "Default script not found"
|
||||
: "Alpine script not found"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'install' && (
|
||||
selectedVersion === 'default' && scriptContent.installScript ? (
|
||||
))}
|
||||
{activeTab === "install" &&
|
||||
(selectedVersion === "default" &&
|
||||
scriptContent.installScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
padding: "1rem",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.installScript}
|
||||
</SyntaxHighlighter>
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
|
||||
) : selectedVersion === "alpine" &&
|
||||
scriptContent.alpineInstallScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
padding: "1rem",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
minHeight: "100%",
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
@@ -324,13 +359,14 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
{scriptContent.alpineInstallScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-lg">
|
||||
{selectedVersion === "default"
|
||||
? "Default install script not found"
|
||||
: "Alpine install script not found"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useState, startTransition } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
@@ -31,9 +31,13 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
|
||||
setThemeState(savedTheme);
|
||||
startTransition(() => {
|
||||
setThemeState(savedTheme);
|
||||
});
|
||||
}
|
||||
setMounted(true);
|
||||
startTransition(() => {
|
||||
setMounted(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Apply theme to document element
|
||||
|
||||
234
src/app/_components/UpdateConfirmationModal.tsx
Normal file
234
src/app/_components/UpdateConfirmationModal.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { X, ExternalLink, Calendar, Tag, AlertTriangle } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface UpdateConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
releaseInfo: {
|
||||
tagName: string;
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
htmlUrl: string;
|
||||
body?: string;
|
||||
} | null;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
}
|
||||
|
||||
export function UpdateConfirmationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
releaseInfo,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
}: UpdateConfirmationModalProps) {
|
||||
useRegisterModal(isOpen, {
|
||||
id: "update-confirmation-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
|
||||
if (!isOpen || !releaseInfo) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="border-border flex items-center justify-between border-b p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="text-warning h-6 w-6" />
|
||||
<div>
|
||||
<h2 className="text-card-foreground text-2xl font-bold">
|
||||
Confirm Update
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Review the changelog before proceeding with the update
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
||||
{/* Version Info */}
|
||||
<div className="bg-muted/50 border-border rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-card-foreground text-lg font-semibold">
|
||||
{releaseInfo.name || releaseInfo.tagName}
|
||||
</h3>
|
||||
<Badge variant="default" className="text-xs">
|
||||
Latest
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<a
|
||||
href={releaseInfo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-3 flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tag className="h-4 w-4" />
|
||||
<span>{releaseInfo.tagName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
{new Date(releaseInfo.publishedAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<span>Updating from </span>
|
||||
<span className="text-card-foreground font-medium">
|
||||
v{currentVersion}
|
||||
</span>
|
||||
<span> to </span>
|
||||
<span className="text-card-foreground font-medium">
|
||||
v{latestVersion}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changelog */}
|
||||
{releaseInfo.body ? (
|
||||
<div className="border-border bg-card rounded-lg border p-6">
|
||||
<h4 className="text-md text-card-foreground mb-4 font-semibold">
|
||||
Changelog
|
||||
</h4>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-card-foreground mt-6 mb-4 text-2xl font-bold">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-card-foreground mt-5 mb-3 text-xl font-semibold">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-card-foreground mt-4 mb-2 text-lg font-medium">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-card-foreground mb-3 leading-relaxed">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="text-card-foreground mb-3 list-inside list-disc space-y-1">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="text-card-foreground mb-3 list-inside list-decimal space-y-1">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="text-card-foreground">{children}</li>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-info hover:text-info/80 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="text-card-foreground font-semibold">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="text-card-foreground italic">
|
||||
{children}
|
||||
</em>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{releaseInfo.body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-border bg-card rounded-lg border p-6">
|
||||
<p className="text-muted-foreground">
|
||||
No changelog available for this release.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-warning/10 border-warning/30 rounded-lg border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="text-warning mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div className="text-card-foreground text-sm">
|
||||
<p className="mb-1 font-medium">Important:</p>
|
||||
<p className="text-muted-foreground">
|
||||
Please review the changelog above for any breaking changes
|
||||
or important updates before proceeding. The server will
|
||||
restart automatically after the update completes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-border bg-muted/30 flex items-center justify-between border-t p-6">
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} variant="destructive" className="gap-2">
|
||||
<span>Proceed with Update</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import { api } from "~/trpc/react";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
import { UpdateConfirmationModal } from "./UpdateConfirmationModal";
|
||||
|
||||
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface VersionDisplayProps {
|
||||
onOpenReleaseNotes?: () => void;
|
||||
@@ -85,55 +86,233 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
|
||||
const [shouldSubscribe, setShouldSubscribe] = useState(false);
|
||||
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
||||
const lastLogTimeRef = useRef<number>(Date.now());
|
||||
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
|
||||
const lastLogTimeRef = useRef<number>(0);
|
||||
|
||||
// Initialize lastLogTimeRef in useEffect to avoid calling Date.now() during render
|
||||
useEffect(() => {
|
||||
if (lastLogTimeRef.current === 0) {
|
||||
lastLogTimeRef.current = Date.now();
|
||||
}
|
||||
}, []);
|
||||
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasReloadedRef = useRef<boolean>(false);
|
||||
const isUpdatingRef = useRef<boolean>(false);
|
||||
const isNetworkErrorRef = useRef<boolean>(false);
|
||||
const updateSessionIdRef = useRef<string | null>(null);
|
||||
const updateStartTimeRef = useRef<number | null>(null);
|
||||
const logFileModifiedTimeRef = useRef<number | null>(null);
|
||||
const isCompleteProcessedRef = useRef<boolean>(false);
|
||||
|
||||
const executeUpdate = api.version.executeUpdate.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setUpdateResult({ success: result.success, message: result.message });
|
||||
|
||||
if (result.success) {
|
||||
// Start subscribing to update logs
|
||||
setShouldSubscribe(true);
|
||||
setUpdateLogs(['Update started...']);
|
||||
// Start subscribing to update logs only if we're actually updating
|
||||
if (isUpdatingRef.current) {
|
||||
setShouldSubscribe(true);
|
||||
setUpdateLogs(['Update started...']);
|
||||
}
|
||||
} else {
|
||||
setIsUpdating(false);
|
||||
setShouldSubscribe(false); // Reset subscription on failure
|
||||
updateSessionIdRef.current = null;
|
||||
updateStartTimeRef.current = null;
|
||||
logFileModifiedTimeRef.current = null;
|
||||
isCompleteProcessedRef.current = false;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setUpdateResult({ success: false, message: error.message });
|
||||
setIsUpdating(false);
|
||||
setShouldSubscribe(false); // Reset subscription on error
|
||||
updateSessionIdRef.current = null;
|
||||
updateStartTimeRef.current = null;
|
||||
logFileModifiedTimeRef.current = null;
|
||||
isCompleteProcessedRef.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Poll for update logs
|
||||
// Poll for update logs - only enabled when shouldSubscribe is true AND we're updating
|
||||
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
|
||||
enabled: shouldSubscribe,
|
||||
refetchInterval: 1000, // Poll every second
|
||||
refetchIntervalInBackground: true,
|
||||
enabled: shouldSubscribe && isUpdating,
|
||||
refetchInterval: shouldSubscribe && isUpdating ? 1000 : false, // Poll every second only when updating
|
||||
refetchIntervalInBackground: false, // Don't poll in background to prevent stale data
|
||||
});
|
||||
|
||||
// Attempt to reconnect and reload page when server is back
|
||||
// Memoized with useCallback to prevent recreation on every render
|
||||
// Only depends on refs to avoid stale closures
|
||||
const startReconnectAttempts = useCallback(() => {
|
||||
// CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts
|
||||
// Only start if we're actually updating and haven't already started
|
||||
// Double-check isUpdating state and session validity to prevent false triggers from stale data
|
||||
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate session age before starting reconnection attempts
|
||||
const sessionAge = Date.now() - updateStartTimeRef.current;
|
||||
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
|
||||
if (sessionAge > MAX_SESSION_AGE) {
|
||||
// Session is stale, don't start reconnection
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
|
||||
|
||||
reconnectIntervalRef.current = setInterval(() => {
|
||||
void (async () => {
|
||||
// Guard: Only proceed if we're still updating and in network error state
|
||||
// Check refs directly to avoid stale closures
|
||||
if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
|
||||
// Clear interval if we're no longer updating
|
||||
if (!isUpdatingRef.current && reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate session is still valid
|
||||
const currentSessionAge = Date.now() - updateStartTimeRef.current;
|
||||
if (currentSessionAge > MAX_SESSION_AGE) {
|
||||
// Session expired, stop reconnection attempts
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to fetch the root path to check if server is back
|
||||
const response = await fetch('/', { method: 'HEAD' });
|
||||
if (response.ok || response.status === 200) {
|
||||
// Double-check we're still updating and session is valid before reloading
|
||||
if (!isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Final session validation
|
||||
const finalSessionAge = Date.now() - updateStartTimeRef.current;
|
||||
if (finalSessionAge > MAX_SESSION_AGE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark that we're about to reload to prevent multiple reloads
|
||||
hasReloadedRef.current = true;
|
||||
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
|
||||
|
||||
// Clear interval
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Clear any existing reload timeout
|
||||
if (reloadTimeoutRef.current) {
|
||||
clearTimeout(reloadTimeoutRef.current);
|
||||
reloadTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Set reload timeout
|
||||
reloadTimeoutRef.current = setTimeout(() => {
|
||||
reloadTimeoutRef.current = null;
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
} catch {
|
||||
// Server still down, keep trying
|
||||
}
|
||||
})();
|
||||
}, 2000);
|
||||
}, []); // Empty deps - only uses refs which are stable
|
||||
|
||||
// Update logs when data changes
|
||||
useEffect(() => {
|
||||
// CRITICAL: Only process update logs if we're actually updating
|
||||
// This prevents stale isComplete data from triggering reloads when not updating
|
||||
if (!isUpdating || !updateStartTimeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Validate session - only process logs from current update session
|
||||
// Check that update started within last 30 minutes (reasonable window for update)
|
||||
const sessionAge = Date.now() - updateStartTimeRef.current;
|
||||
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
|
||||
if (sessionAge > MAX_SESSION_AGE) {
|
||||
// Session is stale, reset everything
|
||||
setTimeout(() => {
|
||||
setIsUpdating(false);
|
||||
setShouldSubscribe(false);
|
||||
}, 0);
|
||||
updateSessionIdRef.current = null;
|
||||
updateStartTimeRef.current = null;
|
||||
logFileModifiedTimeRef.current = null;
|
||||
isCompleteProcessedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateLogsData?.success && updateLogsData.logs) {
|
||||
lastLogTimeRef.current = Date.now();
|
||||
setUpdateLogs(updateLogsData.logs);
|
||||
|
||||
if (updateLogsData.isComplete) {
|
||||
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
||||
setIsNetworkError(true);
|
||||
if (updateLogsData.logFileModifiedTime !== null && logFileModifiedTimeRef.current !== null) {
|
||||
|
||||
if (updateLogsData.logFileModifiedTime < logFileModifiedTimeRef.current) {
|
||||
|
||||
return;
|
||||
}
|
||||
} else if (updateLogsData.logFileModifiedTime !== null && updateStartTimeRef.current) {
|
||||
|
||||
const timeDiff = updateLogsData.logFileModifiedTime - updateStartTimeRef.current;
|
||||
if (timeDiff < -5000) {
|
||||
|
||||
}
|
||||
logFileModifiedTimeRef.current = updateLogsData.logFileModifiedTime;
|
||||
}
|
||||
|
||||
lastLogTimeRef.current = Date.now();
|
||||
setTimeout(() => setUpdateLogs(updateLogsData.logs), 0);
|
||||
|
||||
|
||||
if (
|
||||
updateLogsData.isComplete &&
|
||||
isUpdating &&
|
||||
updateStartTimeRef.current &&
|
||||
sessionAge < MAX_SESSION_AGE &&
|
||||
!isCompleteProcessedRef.current
|
||||
) {
|
||||
// Mark as processed immediately to prevent multiple triggers
|
||||
isCompleteProcessedRef.current = true;
|
||||
|
||||
// Stop polling immediately to prevent further stale data processing
|
||||
setTimeout(() => setShouldSubscribe(false), 0);
|
||||
|
||||
setTimeout(() => {
|
||||
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
||||
setIsNetworkError(true);
|
||||
}, 0);
|
||||
|
||||
// Start reconnection attempts when we know update is complete
|
||||
startReconnectAttempts();
|
||||
setTimeout(() => startReconnectAttempts(), 0);
|
||||
}
|
||||
}
|
||||
}, [updateLogsData]);
|
||||
}, [updateLogsData, startReconnectAttempts, isUpdating]);
|
||||
|
||||
// Monitor for server connection loss and auto-reload (fallback only)
|
||||
useEffect(() => {
|
||||
if (!shouldSubscribe) return;
|
||||
// Early return: only run if we're actually updating
|
||||
if (!shouldSubscribe || !isUpdating) return;
|
||||
|
||||
// Only use this as a fallback - the main trigger should be completion detection
|
||||
const checkInterval = setInterval(() => {
|
||||
// Check refs first to ensure we're still updating
|
||||
if (!isUpdatingRef.current || hasReloadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
|
||||
|
||||
// Only start reconnection if we've been updating for at least 3 minutes
|
||||
@@ -141,7 +320,10 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
||||
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
||||
|
||||
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
|
||||
// Additional guard: check refs again before triggering and validate session
|
||||
const sessionAge = updateStartTimeRef.current ? Date.now() - updateStartTimeRef.current : Infinity;
|
||||
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
|
||||
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current && updateStartTimeRef.current && sessionAge < MAX_SESSION_AGE) {
|
||||
setIsNetworkError(true);
|
||||
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
||||
|
||||
@@ -151,55 +333,121 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
}, 10000); // Check every 10 seconds
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
|
||||
}, [shouldSubscribe, isUpdating, updateStartTime, startReconnectAttempts]);
|
||||
|
||||
// Attempt to reconnect and reload page when server is back
|
||||
const startReconnectAttempts = () => {
|
||||
if (reconnectIntervalRef.current) return;
|
||||
|
||||
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
|
||||
|
||||
reconnectIntervalRef.current = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
// Try to fetch the root path to check if server is back
|
||||
const response = await fetch('/', { method: 'HEAD' });
|
||||
if (response.ok || response.status === 200) {
|
||||
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
|
||||
|
||||
// Clear interval and reload
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
} catch {
|
||||
// Server still down, keep trying
|
||||
}
|
||||
})();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Cleanup reconnect interval on unmount
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
isUpdatingRef.current = isUpdating;
|
||||
// CRITICAL: Reset shouldSubscribe immediately when isUpdating becomes false
|
||||
// This prevents stale polling from continuing
|
||||
if (!isUpdating) {
|
||||
setTimeout(() => {
|
||||
setShouldSubscribe(false);
|
||||
}, 0);
|
||||
// Reset completion processing flag when update stops
|
||||
isCompleteProcessedRef.current = false;
|
||||
}
|
||||
}, [isUpdating]);
|
||||
|
||||
useEffect(() => {
|
||||
isNetworkErrorRef.current = isNetworkError;
|
||||
}, [isNetworkError]);
|
||||
|
||||
// Keep updateStartTime ref in sync
|
||||
useEffect(() => {
|
||||
updateStartTimeRef.current = updateStartTime;
|
||||
}, [updateStartTime]);
|
||||
|
||||
// Clear reconnect interval when update completes or component unmounts
|
||||
useEffect(() => {
|
||||
// If we're no longer updating, clear the reconnect interval and reset subscription
|
||||
if (!isUpdating) {
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
// Clear reload timeout if update stops
|
||||
if (reloadTimeoutRef.current) {
|
||||
clearTimeout(reloadTimeoutRef.current);
|
||||
reloadTimeoutRef.current = null;
|
||||
}
|
||||
// Reset subscription to prevent stale polling
|
||||
setTimeout(() => {
|
||||
setShouldSubscribe(false);
|
||||
}, 0);
|
||||
// Reset completion processing flag
|
||||
isCompleteProcessedRef.current = false;
|
||||
// Don't clear session refs here - they're cleared explicitly on unmount or new update
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
if (reloadTimeoutRef.current) {
|
||||
clearTimeout(reloadTimeoutRef.current);
|
||||
reloadTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isUpdating]);
|
||||
|
||||
// Cleanup on component unmount - reset all update-related state
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear all intervals
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
// Reset all refs and state
|
||||
updateSessionIdRef.current = null;
|
||||
updateStartTimeRef.current = null;
|
||||
logFileModifiedTimeRef.current = null;
|
||||
isCompleteProcessedRef.current = false;
|
||||
hasReloadedRef.current = false;
|
||||
isUpdatingRef.current = false;
|
||||
isNetworkErrorRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUpdate = () => {
|
||||
// Show confirmation modal instead of starting update directly
|
||||
setShowUpdateConfirmation(true);
|
||||
};
|
||||
|
||||
const handleConfirmUpdate = () => {
|
||||
// Close the confirmation modal
|
||||
setShowUpdateConfirmation(false);
|
||||
// Start the actual update process
|
||||
const sessionId = `update_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
setIsUpdating(true);
|
||||
setUpdateResult(null);
|
||||
setIsNetworkError(false);
|
||||
setUpdateLogs([]);
|
||||
setShouldSubscribe(false);
|
||||
setUpdateStartTime(Date.now());
|
||||
lastLogTimeRef.current = Date.now();
|
||||
setShouldSubscribe(false); // Will be set to true in mutation onSuccess
|
||||
setUpdateStartTime(startTime);
|
||||
|
||||
// Set refs for session tracking
|
||||
updateSessionIdRef.current = sessionId;
|
||||
updateStartTimeRef.current = startTime;
|
||||
lastLogTimeRef.current = startTime;
|
||||
logFileModifiedTimeRef.current = null; // Will be set when we first see log file
|
||||
isCompleteProcessedRef.current = false; // Reset completion flag
|
||||
hasReloadedRef.current = false; // Reset reload flag when starting new update
|
||||
|
||||
// Clear any existing reconnect interval and reload timeout
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
if (reloadTimeoutRef.current) {
|
||||
clearTimeout(reloadTimeoutRef.current);
|
||||
reloadTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
executeUpdate.mutate();
|
||||
};
|
||||
|
||||
@@ -233,6 +481,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"}
|
||||
|
||||
@@ -41,10 +41,14 @@ export async function POST(request: NextRequest) {
|
||||
const sessionDurationDays = authConfig.sessionDurationDays;
|
||||
const token = generateToken(username, sessionDurationDays);
|
||||
|
||||
// Calculate expiration time for client
|
||||
const expirationTime = Date.now() + (sessionDurationDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
username
|
||||
username,
|
||||
expirationTime
|
||||
});
|
||||
|
||||
// Determine if request is over HTTPS
|
||||
@@ -54,7 +58,7 @@ export async function POST(request: NextRequest) {
|
||||
response.cookies.set('auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: isSecure, // Only secure if actually over HTTPS
|
||||
sameSite: 'strict',
|
||||
sameSite: 'lax', // Use lax for cross-origin navigation support
|
||||
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
|
||||
path: '/',
|
||||
});
|
||||
|
||||
@@ -3,6 +3,14 @@ import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '../../../../../server/database-prisma';
|
||||
import { getSSHService } from '../../../../../server/ssh-service';
|
||||
|
||||
interface ServerData {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
ssh_key_path?: string | null;
|
||||
key_generated?: boolean;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -18,7 +26,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(id);
|
||||
const server = await db.getServerById(id) as ServerData | null;
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
@@ -28,14 +36,14 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Only allow viewing public key if it was generated by the system
|
||||
if (!(server as any).key_generated) {
|
||||
if (!server.key_generated) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Public key not available for user-provided keys' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!(server as any).ssh_key_path) {
|
||||
if (!server.ssh_key_path) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SSH key path not found' },
|
||||
{ status: 404 }
|
||||
@@ -43,13 +51,13 @@ export async function GET(
|
||||
}
|
||||
|
||||
const sshService = getSSHService();
|
||||
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
|
||||
const publicKey = sshService.getPublicKey(server.ssh_key_path);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
publicKey,
|
||||
serverName: (server as any).name,
|
||||
serverIp: (server as any).ip
|
||||
serverName: server.name,
|
||||
serverIp: server.ip
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error retrieving public key:', error);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const POST = withApiLogging(async function POST(_request: NextRequest) {
|
||||
// Get the next available server ID for key file naming
|
||||
const serverId = await db.getNextServerId();
|
||||
|
||||
const keyPair = await sshService.generateKeyPair(serverId);
|
||||
const keyPair = await sshService.generateKeyPair(Number(serverId));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -4,9 +4,25 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { isValidCron } from 'cron-validator';
|
||||
|
||||
interface AutoSyncSettings {
|
||||
autoSyncEnabled: boolean;
|
||||
syncIntervalType: string;
|
||||
syncIntervalPredefined?: string;
|
||||
syncIntervalCron?: string;
|
||||
autoDownloadNew: boolean;
|
||||
autoUpdateExisting: boolean;
|
||||
notificationEnabled: boolean;
|
||||
appriseUrls?: string[] | string;
|
||||
lastAutoSync?: string;
|
||||
lastAutoSyncError?: string;
|
||||
lastAutoSyncErrorTime?: string;
|
||||
testNotification?: boolean;
|
||||
triggerManualSync?: boolean;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const settings = await request.json();
|
||||
const settings = await request.json() as AutoSyncSettings;
|
||||
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return NextResponse.json(
|
||||
@@ -54,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||
// Validate predefined interval
|
||||
if (settings.syncIntervalType === 'predefined') {
|
||||
const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours'];
|
||||
if (!validIntervals.includes(settings.syncIntervalPredefined)) {
|
||||
if (!settings.syncIntervalPredefined || !validIntervals.includes(settings.syncIntervalPredefined)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid predefined interval' },
|
||||
{ status: 400 }
|
||||
@@ -67,7 +83,7 @@ export async function POST(request: NextRequest) {
|
||||
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') {
|
||||
// Fallback to predefined if custom is selected but no cron expression
|
||||
settings.syncIntervalType = 'predefined';
|
||||
settings.syncIntervalPredefined = settings.syncIntervalPredefined || '1hour';
|
||||
settings.syncIntervalPredefined = settings.syncIntervalPredefined ?? '1hour';
|
||||
settings.syncIntervalCron = '';
|
||||
} else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
|
||||
return NextResponse.json(
|
||||
@@ -109,7 +125,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid JSON format for Apprise URLs' },
|
||||
{ status: 400 }
|
||||
@@ -130,15 +146,15 @@ export async function POST(request: NextRequest) {
|
||||
const autoSyncSettings = {
|
||||
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false',
|
||||
'SYNC_INTERVAL_TYPE': settings.syncIntervalType,
|
||||
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined || '',
|
||||
'SYNC_INTERVAL_CRON': settings.syncIntervalCron || '',
|
||||
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined ?? '',
|
||||
'SYNC_INTERVAL_CRON': settings.syncIntervalCron ?? '',
|
||||
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false',
|
||||
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
|
||||
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
|
||||
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls || '[]'),
|
||||
'LAST_AUTO_SYNC': settings.lastAutoSync || '',
|
||||
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
|
||||
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
|
||||
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls ?? '[]'),
|
||||
'LAST_AUTO_SYNC': settings.lastAutoSync ?? '',
|
||||
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError ?? '',
|
||||
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime ?? ''
|
||||
};
|
||||
|
||||
// Update or add each setting
|
||||
@@ -160,18 +176,27 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Reschedule auto-sync service with new settings
|
||||
try {
|
||||
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit.js');
|
||||
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit');
|
||||
let autoSyncService = getAutoSyncService();
|
||||
|
||||
// If no global instance exists, create one
|
||||
if (!autoSyncService) {
|
||||
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
|
||||
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
|
||||
autoSyncService = new AutoSyncService();
|
||||
setAutoSyncService(autoSyncService);
|
||||
}
|
||||
|
||||
// Update the global service instance with new settings
|
||||
autoSyncService.saveSettings(settings);
|
||||
// Normalize appriseUrls to always be an array
|
||||
const normalizedSettings = {
|
||||
...settings,
|
||||
appriseUrls: Array.isArray(settings.appriseUrls)
|
||||
? settings.appriseUrls
|
||||
: settings.appriseUrls
|
||||
? [settings.appriseUrls]
|
||||
: undefined
|
||||
};
|
||||
autoSyncService.saveSettings(normalizedSettings);
|
||||
|
||||
if (settings.autoSyncEnabled) {
|
||||
autoSyncService.scheduleAutoSync();
|
||||
@@ -180,7 +205,7 @@ export async function POST(request: NextRequest) {
|
||||
// Ensure the service is completely stopped and won't restart
|
||||
autoSyncService.isRunning = false;
|
||||
// Also stop the global service instance if it exists
|
||||
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit.js');
|
||||
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit');
|
||||
stopGlobalAutoSync();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -231,21 +256,21 @@ export async function GET() {
|
||||
autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true',
|
||||
syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined',
|
||||
syncIntervalPredefined: getEnvValue(envContent, 'SYNC_INTERVAL_PREDEFINED') || '1hour',
|
||||
syncIntervalCron: getEnvValue(envContent, 'SYNC_INTERVAL_CRON') || '',
|
||||
syncIntervalCron: getEnvValue(envContent, 'SYNC_INTERVAL_CRON') ?? '',
|
||||
autoDownloadNew: getEnvValue(envContent, 'AUTO_DOWNLOAD_NEW') === 'true',
|
||||
autoUpdateExisting: getEnvValue(envContent, 'AUTO_UPDATE_EXISTING') === 'true',
|
||||
notificationEnabled: getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true',
|
||||
appriseUrls: (() => {
|
||||
try {
|
||||
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
|
||||
return JSON.parse(urlsValue);
|
||||
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') ?? '[]';
|
||||
return JSON.parse(urlsValue) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') || '',
|
||||
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') || null,
|
||||
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') || null
|
||||
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') ?? '',
|
||||
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') ?? null,
|
||||
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') ?? null
|
||||
};
|
||||
|
||||
return NextResponse.json({ settings });
|
||||
@@ -275,8 +300,8 @@ async function handleTestNotification() {
|
||||
const notificationEnabled = getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true';
|
||||
const appriseUrls = (() => {
|
||||
try {
|
||||
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
|
||||
return JSON.parse(urlsValue);
|
||||
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') ?? '[]';
|
||||
return JSON.parse(urlsValue) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -289,7 +314,7 @@ async function handleTestNotification() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!appriseUrls || appriseUrls.length === 0) {
|
||||
if (!appriseUrls?.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No Apprise URLs configured' },
|
||||
{ status: 400 }
|
||||
@@ -297,7 +322,7 @@ async function handleTestNotification() {
|
||||
}
|
||||
|
||||
// Send test notification using the auto-sync service
|
||||
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
|
||||
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
|
||||
const autoSyncService = new AutoSyncService();
|
||||
const result = await autoSyncService.testNotification();
|
||||
|
||||
@@ -345,11 +370,11 @@ async function handleManualSync() {
|
||||
}
|
||||
|
||||
// Trigger manual sync using the auto-sync service
|
||||
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
|
||||
const { AutoSyncService } = await import('../../../../server/services/autoSyncService');
|
||||
const autoSyncService = new AutoSyncService();
|
||||
const result = await autoSyncService.executeAutoSync() as any;
|
||||
const result = await autoSyncService.executeAutoSync() as { success: boolean; message?: string } | null;
|
||||
|
||||
if (result && result.success) {
|
||||
if (result?.success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Manual sync completed successfully',
|
||||
@@ -357,7 +382,7 @@ async function handleManualSync() {
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: result.message },
|
||||
{ error: result?.message ?? 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -376,7 +401,7 @@ function getEnvValue(envContent: string, key: string): string {
|
||||
const regex = new RegExp(`^${key}="(.+)"$`, 'm');
|
||||
let match = regex.exec(envContent);
|
||||
|
||||
if (match && match[1]) {
|
||||
if (match?.[1]) {
|
||||
let value = match[1];
|
||||
// Remove extra quotes that might be around JSON values
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
@@ -388,7 +413,7 @@ function getEnvValue(envContent: string, key: string): string {
|
||||
// Try to match without quotes (fallback)
|
||||
const regexNoQuotes = new RegExp(`^${key}=([^\\s]*)$`, 'm');
|
||||
match = regexNoQuotes.exec(envContent);
|
||||
if (match && match[1]) {
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
|
||||
302
src/app/page.tsx
302
src/app/page.tsx
@@ -1,51 +1,71 @@
|
||||
"use client";
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||
import { BackupsTab } from './_components/BackupsTab';
|
||||
import { ResyncButton } from './_components/ResyncButton';
|
||||
import { Terminal } from './_components/Terminal';
|
||||
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||
import { SettingsButton } from './_components/SettingsButton';
|
||||
import { HelpButton } from './_components/HelpButton';
|
||||
import { VersionDisplay } from './_components/VersionDisplay';
|
||||
import { ThemeToggle } from './_components/ThemeToggle';
|
||||
import { Button } from './_components/ui/button';
|
||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||
import { Footer } from './_components/Footer';
|
||||
import { Package, HardDrive, FolderOpen, LogOut, Archive } from 'lucide-react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useAuth } from './_components/AuthProvider';
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ScriptsGrid } from "./_components/ScriptsGrid";
|
||||
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
|
||||
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
|
||||
import { BackupsTab } from "./_components/BackupsTab";
|
||||
import { ResyncButton } from "./_components/ResyncButton";
|
||||
import { Terminal } from "./_components/Terminal";
|
||||
import { ServerSettingsButton } from "./_components/ServerSettingsButton";
|
||||
import { SettingsButton } from "./_components/SettingsButton";
|
||||
import { HelpButton } from "./_components/HelpButton";
|
||||
import { VersionDisplay } from "./_components/VersionDisplay";
|
||||
import { ThemeToggle } from "./_components/ThemeToggle";
|
||||
import { Button } from "./_components/ui/button";
|
||||
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon";
|
||||
import {
|
||||
ReleaseNotesModal,
|
||||
getLastSeenVersion,
|
||||
} from "./_components/ReleaseNotesModal";
|
||||
import { Footer } from "./_components/Footer";
|
||||
import { Package, HardDrive, FolderOpen, LogOut, Archive } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useAuth } from "./_components/AuthProvider";
|
||||
import type { Server } from "~/types/server";
|
||||
import type { ScriptCard } from "~/types/script";
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed' | 'backups'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed' | 'backups';
|
||||
return savedTab || 'scripts';
|
||||
const [runningScript, setRunningScript] = useState<{
|
||||
path: string;
|
||||
name: string;
|
||||
mode?: "local" | "ssh";
|
||||
server?: Server;
|
||||
} | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"scripts" | "downloaded" | "installed" | "backups"
|
||||
>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const savedTab = localStorage.getItem("activeTab") as
|
||||
| "scripts"
|
||||
| "downloaded"
|
||||
| "installed"
|
||||
| "backups";
|
||||
return savedTab || "scripts";
|
||||
}
|
||||
return 'scripts';
|
||||
return "scripts";
|
||||
});
|
||||
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
|
||||
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
|
||||
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch data for script counts
|
||||
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: scriptCardsData } =
|
||||
api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData } =
|
||||
api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: installedScriptsData } =
|
||||
api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
|
||||
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||
|
||||
// Save active tab to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('activeTab', activeTab);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("activeTab", activeTab);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
@@ -54,9 +74,12 @@ export default function Home() {
|
||||
if (versionData?.success && versionData.version) {
|
||||
const currentVersion = versionData.version;
|
||||
const lastSeenVersion = getLastSeenVersion();
|
||||
|
||||
|
||||
// If we have a current version and either no last seen version or versions don't match
|
||||
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
|
||||
if (
|
||||
currentVersion &&
|
||||
(!lastSeenVersion || currentVersion !== lastSeenVersion)
|
||||
) {
|
||||
setHighlightVersion(currentVersion);
|
||||
setReleaseNotesOpen(true);
|
||||
}
|
||||
@@ -77,11 +100,11 @@ export default function Home() {
|
||||
const scriptCounts = {
|
||||
available: (() => {
|
||||
if (!scriptCardsData?.success) return 0;
|
||||
|
||||
|
||||
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
|
||||
const scriptMap = new Map<string, any>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
const scriptMap = new Map<string, ScriptCard>();
|
||||
|
||||
scriptCardsData.cards?.forEach((script: ScriptCard) => {
|
||||
if (script?.name && script?.slug) {
|
||||
// Use slug as unique identifier, only keep first occurrence
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
@@ -89,39 +112,83 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return scriptMap.size;
|
||||
})(),
|
||||
downloaded: (() => {
|
||||
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
||||
|
||||
|
||||
// Helper to normalize identifiers for robust matching
|
||||
const normalizeId = (s?: string): string =>
|
||||
(s ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/\.(sh|bash|py|js|ts)$/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
// First deduplicate GitHub scripts using Map by slug
|
||||
const scriptMap = new Map<string, any>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
const scriptMap = new Map<string, ScriptCard>();
|
||||
|
||||
scriptCardsData.cards?.forEach((script: ScriptCard) => {
|
||||
if (script?.name && script?.slug) {
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
scriptMap.set(script.slug, script);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const deduplicatedGithubScripts = Array.from(scriptMap.values());
|
||||
const localScripts = localScriptsData.scripts ?? [];
|
||||
|
||||
const localScripts = (localScriptsData.scripts ?? []) as Array<{
|
||||
name?: string;
|
||||
slug?: string;
|
||||
}>;
|
||||
|
||||
// Count scripts that are both in deduplicated GitHub data and have local versions
|
||||
return deduplicatedGithubScripts.filter(script => {
|
||||
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
|
||||
return deduplicatedGithubScripts.filter((script) => {
|
||||
if (!script?.name) return false;
|
||||
return localScripts.some(local => {
|
||||
|
||||
// Check if there's a corresponding local script
|
||||
return localScripts.some((local) => {
|
||||
if (!local?.name) return false;
|
||||
const localName = local.name.replace(/\.sh$/, '');
|
||||
return localName.toLowerCase() === script.name.toLowerCase() ||
|
||||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
|
||||
|
||||
// Primary: Exact slug-to-slug matching (most reliable)
|
||||
if (local.slug && script.slug) {
|
||||
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
|
||||
if (
|
||||
normalizeId(local.slug ?? undefined) ===
|
||||
normalizeId(script.slug ?? undefined)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
||||
const normalizedLocal = normalizeId(local.name ?? undefined);
|
||||
const matchesInstallBasename =
|
||||
script.install_basenames?.some(
|
||||
(base) => normalizeId(String(base)) === normalizedLocal,
|
||||
) ?? false;
|
||||
if (matchesInstallBasename) return true;
|
||||
|
||||
// Tertiary: Normalized filename to normalized slug matching
|
||||
if (
|
||||
script.slug &&
|
||||
normalizeId(local.name ?? undefined) ===
|
||||
normalizeId(script.slug ?? undefined)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}).length;
|
||||
})(),
|
||||
installed: installedScriptsData?.scripts?.length ?? 0,
|
||||
backups: backupsData?.success ? backupsData.backups.length : 0
|
||||
backups: backupsData?.success ? backupsData.backups.length : 0,
|
||||
};
|
||||
|
||||
const scrollToTerminal = () => {
|
||||
@@ -129,15 +196,20 @@ export default function Home() {
|
||||
// Get the element's position and scroll with a small offset for better mobile experience
|
||||
const elementTop = terminalRef.current.offsetTop;
|
||||
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
|
||||
|
||||
|
||||
window.scrollTo({
|
||||
top: elementTop - offset,
|
||||
behavior: 'smooth'
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
|
||||
const handleRunScript = (
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
mode?: "local" | "ssh",
|
||||
server?: Server,
|
||||
) => {
|
||||
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
|
||||
// Scroll to terminal after a short delay to ensure it's rendered
|
||||
setTimeout(scrollToTerminal, 100);
|
||||
@@ -148,16 +220,16 @@ export default function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
|
||||
<main className="bg-background min-h-screen">
|
||||
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6 sm:mb-8">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="mb-6 text-center sm:mb-8">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="flex-1"></div>
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
|
||||
<h1 className="text-foreground flex flex-1 items-center justify-center gap-2 text-2xl font-bold sm:gap-3 sm:text-3xl lg:text-4xl">
|
||||
<span className="break-words">PVE Scripts Management</span>
|
||||
</h1>
|
||||
<div className="flex-1 flex justify-end items-center gap-2">
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -173,8 +245,9 @@ export default function Home() {
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
|
||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||
<p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base">
|
||||
Manage and execute Proxmox helper scripts locally with live output
|
||||
streaming
|
||||
</p>
|
||||
<div className="flex justify-center px-2">
|
||||
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
|
||||
@@ -183,7 +256,7 @@ export default function Home() {
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||
<div className="bg-card border-border flex flex-col gap-4 rounded-lg border p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center sm:p-6">
|
||||
<ServerSettingsButton />
|
||||
<SettingsButton />
|
||||
<ResyncButton />
|
||||
@@ -193,72 +266,85 @@ export default function Home() {
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="border-b border-border">
|
||||
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
|
||||
<div className="border-border border-b">
|
||||
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('scripts')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'scripts'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("scripts")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "scripts"
|
||||
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||
}`}
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Available Scripts</span>
|
||||
<span className="sm:hidden">Available</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||
{scriptCounts.available}
|
||||
</span>
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
|
||||
<ContextualHelpIcon
|
||||
section="available-scripts"
|
||||
tooltip="Help with Available Scripts"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('downloaded')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'downloaded'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("downloaded")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "downloaded"
|
||||
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Downloaded Scripts</span>
|
||||
<span className="sm:hidden">Downloaded</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||
{scriptCounts.downloaded}
|
||||
</span>
|
||||
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
|
||||
<ContextualHelpIcon
|
||||
section="downloaded-scripts"
|
||||
tooltip="Help with Downloaded Scripts"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('installed')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'installed'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("installed")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "installed"
|
||||
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Installed Scripts</span>
|
||||
<span className="sm:hidden">Installed</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||
{scriptCounts.installed}
|
||||
</span>
|
||||
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
|
||||
<ContextualHelpIcon
|
||||
section="installed-scripts"
|
||||
tooltip="Help with Installed Scripts"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('backups')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'backups'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("backups")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "backups"
|
||||
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||
}`}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Backups</span>
|
||||
<span className="sm:hidden">Backups</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||
{scriptCounts.backups}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -266,8 +352,6 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Running Script Terminal */}
|
||||
{runningScript && (
|
||||
<div ref={terminalRef} className="mb-8">
|
||||
@@ -281,21 +365,17 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'scripts' && (
|
||||
{activeTab === "scripts" && (
|
||||
<ScriptsGrid onInstallScript={handleRunScript} />
|
||||
)}
|
||||
|
||||
{activeTab === 'downloaded' && (
|
||||
|
||||
{activeTab === "downloaded" && (
|
||||
<DownloadedScriptsTab onInstallScript={handleRunScript} />
|
||||
)}
|
||||
|
||||
{activeTab === 'installed' && (
|
||||
<InstalledScriptsTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'backups' && (
|
||||
<BackupsTab />
|
||||
)}
|
||||
|
||||
{activeTab === "installed" && <InstalledScriptsTab />}
|
||||
|
||||
{activeTab === "backups" && <BackupsTab />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -147,7 +147,7 @@ export function getAuthConfig(): {
|
||||
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
|
||||
const sessionDurationMatch = sessionDurationRegex.exec(envContent);
|
||||
const sessionDurationDays = sessionDurationMatch
|
||||
? parseInt(sessionDurationMatch[1]?.trim() || String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
|
||||
? parseInt(sessionDurationMatch[1]?.trim() ?? String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
|
||||
: DEFAULT_JWT_EXPIRY_DAYS;
|
||||
|
||||
const hasCredentials = !!(username && passwordHash);
|
||||
|
||||
@@ -29,6 +29,7 @@ export const backupsRouter = createTRPCRouter({
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
server_id?: number;
|
||||
server_name: string | null;
|
||||
server_color: string | null;
|
||||
}>;
|
||||
@@ -38,7 +39,7 @@ export const backupsRouter = createTRPCRouter({
|
||||
if (backups.length === 0) continue;
|
||||
|
||||
// Get hostname from first backup (all backups for same container should have same hostname)
|
||||
const hostname = backups[0]?.hostname || '';
|
||||
const hostname = backups[0]?.hostname ?? '';
|
||||
|
||||
result.push({
|
||||
container_id: containerId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-floating-promises */
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { getDatabase } from "~/server/database-prisma";
|
||||
@@ -383,6 +384,88 @@ async function tryLVMResize(
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to determine if a container is a VM or LXC
|
||||
async function isVM(scriptId: number, containerId: string, serverId: number | null): Promise<boolean> {
|
||||
const db = getDatabase();
|
||||
|
||||
// Method 1: Check if LXCConfig exists (if exists, it's an LXC container)
|
||||
const lxcConfig = await db.getLXCConfigByScriptId(scriptId);
|
||||
if (lxcConfig) {
|
||||
return false; // Has LXCConfig, so it's an LXC container
|
||||
}
|
||||
|
||||
// Method 2: If no LXCConfig, check config file paths on server
|
||||
if (!serverId) {
|
||||
// Can't determine without server, default to false (LXC) for safety
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const server = await db.getServerById(serverId);
|
||||
if (!server) {
|
||||
return false; // Default to LXC if server not found
|
||||
}
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Test SSH connection
|
||||
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||
if (!(connectionTest as any).success) {
|
||||
return false; // Default to LXC if SSH fails
|
||||
}
|
||||
|
||||
// Check both config file paths
|
||||
const vmConfigPath = `/etc/pve/qemu-server/${containerId}.conf`;
|
||||
const lxcConfigPath = `/etc/pve/lxc/${containerId}.conf`;
|
||||
|
||||
// Check VM config file
|
||||
let vmConfigExists = false;
|
||||
await new Promise<void>((resolve) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
`test -f "${vmConfigPath}" && echo "exists" || echo "not_exists"`,
|
||||
(data: string) => {
|
||||
if (data.includes('exists')) {
|
||||
vmConfigExists = true;
|
||||
}
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
|
||||
if (vmConfigExists) {
|
||||
return true; // VM config file exists
|
||||
}
|
||||
|
||||
// Check LXC config file
|
||||
let lxcConfigExists = false;
|
||||
await new Promise<void>((resolve) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
`test -f "${lxcConfigPath}" && echo "exists" || echo "not_exists"`,
|
||||
(data: string) => {
|
||||
if (data.includes('exists')) {
|
||||
lxcConfigExists = true;
|
||||
}
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
|
||||
// If LXC config exists, it's an LXC container
|
||||
return !lxcConfigExists; // Return true if it's a VM (neither config exists defaults to false/LXC)
|
||||
} catch (error) {
|
||||
console.error('Error determining container type:', error);
|
||||
return false; // Default to LXC on error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const installedScriptsRouter = createTRPCRouter({
|
||||
// Get all installed scripts
|
||||
@@ -393,18 +476,28 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const scripts = await db.getAllInstalledScripts();
|
||||
|
||||
// Transform scripts to flatten server data for frontend compatibility
|
||||
const transformedScripts = scripts.map(script => ({
|
||||
...script,
|
||||
server_name: script.server?.name ?? null,
|
||||
server_ip: script.server?.ip ?? null,
|
||||
server_user: script.server?.user ?? null,
|
||||
server_password: script.server?.password ?? null,
|
||||
server_auth_type: script.server?.auth_type ?? null,
|
||||
server_ssh_key: script.server?.ssh_key ?? null,
|
||||
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||
server_ssh_port: script.server?.ssh_port ?? null,
|
||||
server_color: script.server?.color ?? null,
|
||||
server: undefined // Remove nested server object
|
||||
|
||||
const transformedScripts = await Promise.all(scripts.map(async (script: any) => {
|
||||
// Determine if it's a VM or LXC
|
||||
let is_vm = false;
|
||||
if (script.container_id && script.server_id) {
|
||||
is_vm = await isVM(script.id, script.container_id, script.server_id);
|
||||
}
|
||||
|
||||
return {
|
||||
...script,
|
||||
server_name: script.server?.name ?? null,
|
||||
server_ip: script.server?.ip ?? null,
|
||||
server_user: script.server?.user ?? null,
|
||||
server_password: script.server?.password ?? null,
|
||||
server_auth_type: script.server?.auth_type ?? null,
|
||||
server_ssh_key: script.server?.ssh_key ?? null,
|
||||
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||
server_ssh_port: script.server?.ssh_port ?? null,
|
||||
server_color: script.server?.color ?? null,
|
||||
is_vm,
|
||||
server: undefined // Remove nested server object
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -430,18 +523,28 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const scripts = await db.getInstalledScriptsByServer(input.serverId);
|
||||
|
||||
// Transform scripts to flatten server data for frontend compatibility
|
||||
const transformedScripts = scripts.map(script => ({
|
||||
...script,
|
||||
server_name: script.server?.name ?? null,
|
||||
server_ip: script.server?.ip ?? null,
|
||||
server_user: script.server?.user ?? null,
|
||||
server_password: script.server?.password ?? null,
|
||||
server_auth_type: script.server?.auth_type ?? null,
|
||||
server_ssh_key: script.server?.ssh_key ?? null,
|
||||
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||
server_ssh_port: script.server?.ssh_port ?? null,
|
||||
server_color: script.server?.color ?? null,
|
||||
server: undefined // Remove nested server object
|
||||
|
||||
const transformedScripts = await Promise.all(scripts.map(async (script: any) => {
|
||||
// Determine if it's a VM or LXC
|
||||
let is_vm = false;
|
||||
if (script.container_id && script.server_id) {
|
||||
is_vm = await isVM(script.id, script.container_id, script.server_id);
|
||||
}
|
||||
|
||||
return {
|
||||
...script,
|
||||
server_name: script.server?.name ?? null,
|
||||
server_ip: script.server?.ip ?? null,
|
||||
server_user: script.server?.user ?? null,
|
||||
server_password: script.server?.password ?? null,
|
||||
server_auth_type: script.server?.auth_type ?? null,
|
||||
server_ssh_key: script.server?.ssh_key ?? null,
|
||||
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||
server_ssh_port: script.server?.ssh_port ?? null,
|
||||
server_color: script.server?.color ?? null,
|
||||
is_vm,
|
||||
server: undefined // Remove nested server object
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -472,6 +575,12 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
script: null
|
||||
};
|
||||
}
|
||||
// Determine if it's a VM or LXC
|
||||
let is_vm = false;
|
||||
if (script.container_id && script.server_id) {
|
||||
is_vm = await isVM(script.id, script.container_id, script.server_id);
|
||||
}
|
||||
|
||||
// Transform script to flatten server data for frontend compatibility
|
||||
const transformedScript = {
|
||||
...script,
|
||||
@@ -484,6 +593,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||
server_ssh_port: script.server?.ssh_port ?? null,
|
||||
server_color: script.server?.color ?? null,
|
||||
is_vm,
|
||||
server: undefined // Remove nested server object
|
||||
};
|
||||
|
||||
@@ -677,113 +787,159 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
|
||||
// Use the working approach - manual loop through all config files
|
||||
const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`;
|
||||
// Get containers from pct list and VMs from qm list
|
||||
let detectedContainers: any[] = [];
|
||||
|
||||
// Helper function to parse list output and extract IDs
|
||||
const parseListOutput = (output: string, _isVM: boolean): string[] => {
|
||||
const ids: string[] = [];
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip header lines
|
||||
if (line.includes('VMID') || line.includes('CTID')) continue;
|
||||
|
||||
// Extract first column (ID)
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > 0) {
|
||||
const id = parts[0]?.trim();
|
||||
// Validate ID format (3-4 digits typically)
|
||||
if (id && /^\d{3,4}$/.test(id)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
let commandOutput = '';
|
||||
|
||||
// Helper function to check config file for community-script tag and extract hostname/name
|
||||
const checkConfigAndExtractInfo = async (id: string, isVM: boolean): Promise<any> => {
|
||||
const configPath = isVM
|
||||
? `/etc/pve/qemu-server/${id}.conf`
|
||||
: `/etc/pve/lxc/${id}.conf`;
|
||||
|
||||
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||
|
||||
return new Promise<any>((resolve) => {
|
||||
let configData = '';
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
readCommand,
|
||||
(data: string) => {
|
||||
configData += data;
|
||||
},
|
||||
(_error: string) => {
|
||||
// Config file doesn't exist or can't be read
|
||||
resolve(null);
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
// Check if config contains community-script tag
|
||||
if (!configData.includes('community-script')) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract hostname (for containers) or name (for VMs)
|
||||
const lines = configData.split('\n');
|
||||
let hostname = '';
|
||||
let name = '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith('hostname:')) {
|
||||
hostname = trimmedLine.substring(9).trim();
|
||||
} else if (trimmedLine.startsWith('name:')) {
|
||||
name = trimmedLine.substring(5).trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Use hostname for containers, name for VMs
|
||||
const displayName = isVM ? name : hostname;
|
||||
|
||||
if (displayName) {
|
||||
// Parse full config and store in database (only for containers)
|
||||
let parsedConfig = null;
|
||||
let configHash = null;
|
||||
|
||||
if (!isVM) {
|
||||
parsedConfig = parseRawConfig(configData);
|
||||
configHash = calculateConfigHash(configData);
|
||||
}
|
||||
|
||||
resolve({
|
||||
containerId: id,
|
||||
hostname: displayName,
|
||||
configPath,
|
||||
isVM,
|
||||
serverId: Number((server as any).id),
|
||||
serverName: (server as any).name,
|
||||
parsedConfig: parsedConfig ? {
|
||||
...parsedConfig,
|
||||
config_hash: configHash,
|
||||
synced_at: new Date()
|
||||
} : null
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Get containers from pct list
|
||||
let pctOutput = '';
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
|
||||
server as Server,
|
||||
command,
|
||||
'pct list',
|
||||
(data: string) => {
|
||||
commandOutput += data;
|
||||
pctOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error('Command error:', error);
|
||||
console.error('pct list error:', error);
|
||||
reject(new Error(`pct list failed: ${error}`));
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
|
||||
// Parse the complete output to get config file paths that contain community-script tag
|
||||
const configFiles = commandOutput.split('\n')
|
||||
.filter((line: string) => line.trim())
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line.endsWith('.conf'));
|
||||
|
||||
|
||||
// Process each config file to extract hostname
|
||||
const processPromises = configFiles.map(async (configPath: string) => {
|
||||
try {
|
||||
const containerId = configPath.split('/').pop()?.replace('.conf', '');
|
||||
if (!containerId) return null;
|
||||
|
||||
|
||||
// Read the config file content
|
||||
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return new Promise<any>((readResolve) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
|
||||
server as Server,
|
||||
readCommand,
|
||||
(configData: string) => {
|
||||
// Parse config file for hostname
|
||||
const lines = configData.split('\n');
|
||||
let hostname = '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith('hostname:')) {
|
||||
hostname = trimmedLine.substring(9).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostname) {
|
||||
// Parse full config and store in database
|
||||
const parsedConfig = parseRawConfig(configData);
|
||||
const configHash = calculateConfigHash(configData);
|
||||
|
||||
const container = {
|
||||
containerId,
|
||||
hostname,
|
||||
configPath,
|
||||
serverId: Number((server as any).id),
|
||||
serverName: (server as any).name,
|
||||
parsedConfig: {
|
||||
...parsedConfig,
|
||||
config_hash: configHash,
|
||||
synced_at: new Date()
|
||||
}
|
||||
};
|
||||
readResolve(container);
|
||||
} else {
|
||||
readResolve(null);
|
||||
}
|
||||
},
|
||||
(readError: string) => {
|
||||
console.error(`Error reading config file ${configPath}:`, readError);
|
||||
readResolve(null);
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
readResolve(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing config file ${configPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all config files to be processed
|
||||
void Promise.all(processPromises).then((results) => {
|
||||
detectedContainers = results.filter(result => result !== null);
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
console.error('Error processing config files:', error);
|
||||
reject(new Error(`Error processing config files: ${error}`));
|
||||
});
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Get VMs from qm list
|
||||
let qmOutput = '';
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
'qm list',
|
||||
(data: string) => {
|
||||
qmOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error('qm list error:', error);
|
||||
reject(new Error(`qm list failed: ${error}`));
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Parse IDs from both lists
|
||||
const containerIds = parseListOutput(pctOutput, false);
|
||||
const vmIds = parseListOutput(qmOutput, true);
|
||||
|
||||
// Check each container/VM for community-script tag
|
||||
const checkPromises = [
|
||||
...containerIds.map(id => checkConfigAndExtractInfo(id, false)),
|
||||
...vmIds.map(id => checkConfigAndExtractInfo(id, true))
|
||||
];
|
||||
|
||||
const results = await Promise.all(checkPromises);
|
||||
detectedContainers = results.filter(result => result !== null);
|
||||
|
||||
|
||||
// Get existing scripts to check for duplicates
|
||||
const existingScripts = await db.getAllInstalledScripts();
|
||||
@@ -816,11 +972,11 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
server_id: container.serverId,
|
||||
execution_mode: 'ssh',
|
||||
status: 'success',
|
||||
output_log: `Auto-detected from LXC config: ${container.configPath}`
|
||||
output_log: `Auto-detected from ${container.isVM ? 'VM' : 'LXC'} config: ${container.configPath}`
|
||||
});
|
||||
|
||||
// Store LXC config in database
|
||||
if (container.parsedConfig) {
|
||||
// Store LXC config in database (only for containers, not VMs)
|
||||
if (container.parsedConfig && !container.isVM) {
|
||||
await db.createLXCConfig(result.id, container.parsedConfig);
|
||||
}
|
||||
|
||||
@@ -836,8 +992,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const message = skippedScripts.length > 0
|
||||
? `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
|
||||
: `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts.`;
|
||||
? `Auto-detection completed. Found ${detectedContainers.length} containers/VMs with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
|
||||
: `Auto-detection completed. Found ${detectedContainers.length} containers/VMs with community-script tag. Added ${createdScripts.length} new scripts.`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -920,11 +1076,32 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get all existing containers from pct list (more reliable than checking config files)
|
||||
const listCommand = 'pct list';
|
||||
let listOutput = '';
|
||||
|
||||
const existingContainerIds = await new Promise<Set<string>>((resolve, reject) => {
|
||||
// Helper function to parse list output and extract IDs
|
||||
const parseListOutput = (output: string): Set<string> => {
|
||||
const ids = new Set<string>();
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip header lines
|
||||
if (line.includes('VMID') || line.includes('CTID')) continue;
|
||||
|
||||
// Extract first column (ID)
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > 0) {
|
||||
const id = parts[0]?.trim();
|
||||
// Validate ID format (3-4 digits typically)
|
||||
if (id && /^\d{3,4}$/.test(id)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
// Get all existing containers from pct list
|
||||
let pctOutput = '';
|
||||
const existingContainerIds = await new Promise<Set<string>>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`);
|
||||
resolve(new Set()); // Treat timeout as no containers found
|
||||
@@ -932,9 +1109,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
listCommand,
|
||||
'pct list',
|
||||
(data: string) => {
|
||||
listOutput += data;
|
||||
pctOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`cleanupOrphanedScripts: error getting container list from server ${String((server as any).name)}:`, error);
|
||||
@@ -943,58 +1120,95 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Parse pct list output to extract container IDs
|
||||
const containerIds = new Set<string>();
|
||||
const lines = listOutput.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
// pct list format: CTID Status Name
|
||||
// Skip header line if present
|
||||
if (line.includes('CTID') || line.includes('VMID')) continue;
|
||||
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > 0) {
|
||||
const containerId = parts[0]?.trim();
|
||||
if (containerId && /^\d{3,4}$/.test(containerId)) {
|
||||
containerIds.add(containerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve(containerIds);
|
||||
resolve(parseListOutput(pctOutput));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Check each script against the list of existing containers
|
||||
// Get all existing VMs from qm list
|
||||
let qmOutput = '';
|
||||
const existingVMIds = await new Promise<Set<string>>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn(`cleanupOrphanedScripts: timeout while getting VM list from server ${String((server as any).name)}`);
|
||||
resolve(new Set()); // Treat timeout as no VMs found
|
||||
}, 20000);
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
'qm list',
|
||||
(data: string) => {
|
||||
qmOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`cleanupOrphanedScripts: error getting VM list from server ${String((server as any).name)}:`, error);
|
||||
clearTimeout(timeout);
|
||||
resolve(new Set()); // Treat error as no VMs found
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(parseListOutput(qmOutput));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Combine both sets - an ID exists if it's in either list
|
||||
const existingIds = new Set<string>([...existingContainerIds, ...existingVMIds]);
|
||||
|
||||
// Check each script against the list of existing containers and VMs
|
||||
for (const scriptData of serverScripts) {
|
||||
try {
|
||||
const containerId = String(scriptData.container_id).trim();
|
||||
|
||||
// Check if container exists in pct list
|
||||
if (!existingContainerIds.has(containerId)) {
|
||||
// Check if ID exists in either pct list (containers) or qm list (VMs)
|
||||
if (!existingIds.has(containerId)) {
|
||||
// Also verify config file doesn't exist as a double-check
|
||||
const checkCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
|
||||
// Check both container and VM config paths
|
||||
const checkContainerCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
|
||||
const checkVMCommand = `test -f "/etc/pve/qemu-server/${containerId}.conf" && echo "exists" || echo "not_found"`;
|
||||
|
||||
const configExists = await new Promise<boolean>((resolve) => {
|
||||
let combinedOutput = '';
|
||||
let resolved = false;
|
||||
let checksCompleted = 0;
|
||||
|
||||
const finish = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
const out = combinedOutput.trim();
|
||||
resolve(out.includes('exists'));
|
||||
checksCompleted++;
|
||||
if (checksCompleted === 2) {
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
const out = combinedOutput.trim();
|
||||
resolve(out.includes('exists'));
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
const out = combinedOutput.trim();
|
||||
resolve(out.includes('exists'));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
// Check container config
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
checkCommand,
|
||||
checkContainerCommand,
|
||||
(data: string) => {
|
||||
combinedOutput += data;
|
||||
},
|
||||
(_error: string) => {
|
||||
// Ignore errors, just check output
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
finish();
|
||||
}
|
||||
);
|
||||
|
||||
// Check VM config
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
checkVMCommand,
|
||||
(data: string) => {
|
||||
combinedOutput += data;
|
||||
},
|
||||
@@ -1002,20 +1216,19 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
// Ignore errors, just check output
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
clearTimeout(timer);
|
||||
finish();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// If container is not in pct list AND config file doesn't exist, it's orphaned
|
||||
// If ID is not in either list AND config file doesn't exist, it's orphaned
|
||||
if (!configExists) {
|
||||
console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (container ${containerId}) from server ${String((server as any).name)}`);
|
||||
console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (ID ${containerId}) from server ${String((server as any).name)}`);
|
||||
await db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
} else {
|
||||
// Config exists but not in pct list - might be in a transitional state, log but don't delete
|
||||
console.warn(`cleanupOrphanedScripts: Container ${containerId} (${String(scriptData.script_name)}) config exists but not in pct list - may be in transitional state`);
|
||||
// Config exists but not in lists - might be in a transitional state, log but don't delete
|
||||
console.warn(`cleanupOrphanedScripts: Container/VM ${containerId} (${String(scriptData.script_name)}) config exists but not in pct/qm list - may be in transitional state`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1080,59 +1293,120 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
continue;
|
||||
}
|
||||
|
||||
// Run pct list to get all container statuses at once
|
||||
const listCommand = 'pct list';
|
||||
let listOutput = '';
|
||||
// Helper function to parse list output and extract statuses
|
||||
const parseListStatuses = (output: string): Record<string, 'running' | 'stopped' | 'unknown'> => {
|
||||
const statuses: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
// Find header line to determine column positions
|
||||
let statusColumnIndex = 1; // Default to second column
|
||||
for (const line of lines) {
|
||||
if (line.includes('STATUS')) {
|
||||
// Parse header to find STATUS column index
|
||||
const headerParts = line.trim().split(/\s+/);
|
||||
const statusIndex = headerParts.findIndex(part => part.includes('STATUS'));
|
||||
if (statusIndex >= 0) {
|
||||
statusColumnIndex = statusIndex;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip header lines
|
||||
if (line.includes('VMID') || line.includes('CTID') || line.includes('STATUS')) continue;
|
||||
|
||||
// Parse line
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > statusColumnIndex) {
|
||||
const id = parts[0]?.trim();
|
||||
const status = parts[statusColumnIndex]?.trim().toLowerCase();
|
||||
|
||||
if (id && /^\d+$/.test(id)) { // Validate ID is numeric
|
||||
// Map status to our status format
|
||||
let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown';
|
||||
if (status === 'running') {
|
||||
mappedStatus = 'running';
|
||||
} else if (status === 'stopped') {
|
||||
mappedStatus = 'stopped';
|
||||
}
|
||||
// All other statuses (paused, locked, suspended, etc.) map to 'unknown'
|
||||
|
||||
statuses[id] = mappedStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statuses;
|
||||
};
|
||||
|
||||
// Run pct list to get all container statuses
|
||||
let pctOutput = '';
|
||||
|
||||
// Add timeout to prevent hanging connections
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('SSH command timeout after 30 seconds')), 30000);
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
|
||||
server as Server,
|
||||
listCommand,
|
||||
(data: string) => {
|
||||
listOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`pct list error on server ${(server as any).name}:`, error);
|
||||
reject(new Error(error));
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
// Parse pct list output
|
||||
const lines = listOutput.split('\n').filter(line => line.trim());
|
||||
for (const line of lines) {
|
||||
// pct list format: CTID Status Name
|
||||
// Example: "100 running my-container"
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 3) {
|
||||
const containerId = parts[0];
|
||||
const status = parts[1];
|
||||
|
||||
if (containerId && status) {
|
||||
// Map pct list status to our status
|
||||
let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown';
|
||||
if (status === 'running') {
|
||||
mappedStatus = 'running';
|
||||
} else if (status === 'stopped') {
|
||||
mappedStatus = 'stopped';
|
||||
}
|
||||
|
||||
statusMap[containerId] = mappedStatus;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, _reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
'pct list',
|
||||
(data: string) => {
|
||||
pctOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`pct list error on server ${(server as any).name}:`, error);
|
||||
// Don't reject, just continue with empty output
|
||||
resolve();
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
timeoutPromise
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Timeout or error getting pct list from server ${(server as any).name}:`, error);
|
||||
}
|
||||
|
||||
// Run qm list to get all VM statuses
|
||||
let qmOutput = '';
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, _reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
'qm list',
|
||||
(data: string) => {
|
||||
qmOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`qm list error on server ${(server as any).name}:`, error);
|
||||
// Don't reject, just continue with empty output
|
||||
resolve();
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
timeoutPromise
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`Timeout or error getting qm list from server ${(server as any).name}:`, error);
|
||||
}
|
||||
|
||||
// Parse both outputs and combine into statusMap
|
||||
const containerStatuses = parseListStatuses(pctOutput);
|
||||
const vmStatuses = parseListStatuses(qmOutput);
|
||||
|
||||
// Merge both status maps (VMs will overwrite containers if same ID, but that's unlikely)
|
||||
Object.assign(statusMap, containerStatuses, vmStatuses);
|
||||
} catch (error) {
|
||||
console.error(`Error processing server ${(server as any).name}:`, error);
|
||||
}
|
||||
@@ -1207,8 +1481,13 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
};
|
||||
}
|
||||
|
||||
// Check container status
|
||||
const statusCommand = `pct status ${scriptData.container_id}`;
|
||||
// Determine if it's a VM or LXC
|
||||
const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
|
||||
|
||||
// Check container status (use qm for VMs, pct for LXC)
|
||||
const statusCommand = vm
|
||||
? `qm status ${scriptData.container_id}`
|
||||
: `pct status ${scriptData.container_id}`;
|
||||
let statusOutput = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -1305,8 +1584,13 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
};
|
||||
}
|
||||
|
||||
// Execute control command
|
||||
const controlCommand = `pct ${input.action} ${scriptData.container_id}`;
|
||||
// Determine if it's a VM or LXC
|
||||
const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
|
||||
|
||||
// Execute control command (use qm for VMs, pct for LXC)
|
||||
const controlCommand = vm
|
||||
? `qm ${input.action} ${scriptData.container_id}`
|
||||
: `pct ${input.action} ${scriptData.container_id}`;
|
||||
let commandOutput = '';
|
||||
let commandError = '';
|
||||
|
||||
@@ -1396,8 +1680,13 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
};
|
||||
}
|
||||
|
||||
// Determine if it's a VM or LXC
|
||||
const vm = await isVM(input.id, scriptData.container_id, scriptData.server_id);
|
||||
|
||||
// First check if container is running and stop it if necessary
|
||||
const statusCommand = `pct status ${scriptData.container_id}`;
|
||||
const statusCommand = vm
|
||||
? `qm status ${scriptData.container_id}`
|
||||
: `pct status ${scriptData.container_id}`;
|
||||
let statusOutput = '';
|
||||
|
||||
try {
|
||||
@@ -1420,8 +1709,10 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
// Check if container is running
|
||||
if (statusOutput.includes('status: running')) {
|
||||
// Stop the container first
|
||||
const stopCommand = `pct stop ${scriptData.container_id}`;
|
||||
// Stop the container first (use qm for VMs, pct for LXC)
|
||||
const stopCommand = vm
|
||||
? `qm stop ${scriptData.container_id}`
|
||||
: `pct stop ${scriptData.container_id}`;
|
||||
let stopOutput = '';
|
||||
let stopError = '';
|
||||
|
||||
@@ -1451,8 +1742,10 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
}
|
||||
|
||||
// Execute destroy command
|
||||
const destroyCommand = `pct destroy ${scriptData.container_id}`;
|
||||
// Execute destroy command (use qm for VMs, pct for LXC)
|
||||
const destroyCommand = vm
|
||||
? `qm destroy ${scriptData.container_id}`
|
||||
: `pct destroy ${scriptData.container_id}`;
|
||||
let commandOutput = '';
|
||||
let commandError = '';
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
|
||||
|
||||
return {
|
||||
success: true,
|
||||
credentials: credentials.map(c => ({
|
||||
credentials: credentials.map((c: { id: number; server_id: number; storage_name: string; pbs_ip: string; pbs_datastore: string; pbs_fingerprint: string; pbs_password: string }) => ({
|
||||
id: c.id,
|
||||
server_id: c.server_id,
|
||||
storage_name: c.storage_name,
|
||||
@@ -109,7 +109,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
|
||||
storage_name: input.storageName,
|
||||
pbs_ip: input.pbs_ip,
|
||||
pbs_datastore: input.pbs_datastore,
|
||||
pbs_password: passwordToSave,
|
||||
pbs_password: passwordToSave ?? '',
|
||||
pbs_fingerprint: input.pbs_fingerprint,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { scriptManager } from "~/server/lib/scripts";
|
||||
@@ -100,7 +101,7 @@ export const scriptsRouter = createTRPCRouter({
|
||||
getAllScripts: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const scripts = await githubJsonService.getAllScripts();
|
||||
const scripts = await localScriptsService.getAllScripts();
|
||||
return { success: true, scripts };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -177,7 +178,7 @@ export const scriptsRouter = createTRPCRouter({
|
||||
const scripts = await localScriptsService.getAllScripts();
|
||||
|
||||
// Create a set of enabled repository URLs for fast lookup
|
||||
const enabledRepoUrls = new Set(enabledRepos.map(repo => repo.url));
|
||||
const enabledRepoUrls = new Set(enabledRepos.map((repo: { url: string }) => repo.url));
|
||||
|
||||
// Create category ID to name mapping
|
||||
const categoryMap: Record<number, string> = {};
|
||||
@@ -188,7 +189,7 @@ export const scriptsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Enhance cards with category information and additional script data
|
||||
const cardsWithCategories = cards.map(card => {
|
||||
const cardsWithCategories = cards.map((card: ScriptCard) => {
|
||||
const script = scripts.find(s => s.slug === card.slug);
|
||||
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
|
||||
|
||||
@@ -225,7 +226,7 @@ export const scriptsRouter = createTRPCRouter({
|
||||
|
||||
// Filter cards to only include scripts from enabled repositories
|
||||
// For backward compatibility, include scripts without repository_url
|
||||
const filteredCards = cardsWithCategories.filter(card => {
|
||||
const filteredCards = cardsWithCategories.filter((card: ScriptCard) => {
|
||||
const repoUrl = card.repository_url;
|
||||
|
||||
// If script has no repository_url, include it for backward compatibility
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { readFile, writeFile, stat } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { spawn } from "child_process";
|
||||
import { env } from "~/env";
|
||||
@@ -111,7 +111,8 @@ export const versionRouter = createTRPCRouter({
|
||||
tagName: release.tag_name,
|
||||
name: release.name,
|
||||
publishedAt: release.published_at,
|
||||
htmlUrl: release.html_url
|
||||
htmlUrl: release.html_url,
|
||||
body: release.body
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -175,10 +176,21 @@ export const versionRouter = createTRPCRouter({
|
||||
return {
|
||||
success: true,
|
||||
logs: [],
|
||||
isComplete: false
|
||||
isComplete: false,
|
||||
logFileModifiedTime: null
|
||||
};
|
||||
}
|
||||
|
||||
// Get log file modification time for session validation
|
||||
let logFileModifiedTime: number | null = null;
|
||||
try {
|
||||
const stats = await stat(logPath);
|
||||
logFileModifiedTime = stats.mtimeMs;
|
||||
} catch (statError) {
|
||||
// If we can't get stats, continue without timestamp
|
||||
console.warn('Could not get log file stats:', statError);
|
||||
}
|
||||
|
||||
const logs = await readFile(logPath, 'utf-8');
|
||||
const logLines = logs.split('\n')
|
||||
.filter(line => line.trim())
|
||||
@@ -201,7 +213,8 @@ export const versionRouter = createTRPCRouter({
|
||||
return {
|
||||
success: true,
|
||||
logs: logLines,
|
||||
isComplete
|
||||
isComplete,
|
||||
logFileModifiedTime
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading update logs:', error);
|
||||
@@ -209,7 +222,8 @@ export const versionRouter = createTRPCRouter({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to read update logs',
|
||||
logs: [],
|
||||
isComplete: false
|
||||
isComplete: false,
|
||||
logFileModifiedTime: null
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -9,10 +9,10 @@ class DatabaseServicePrisma {
|
||||
}
|
||||
|
||||
init() {
|
||||
// Ensure data/ssh-keys directory exists
|
||||
// Ensure data/ssh-keys directory exists (recursive to create parent dirs)
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
if (!existsSync(sshKeysDir)) {
|
||||
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,26 +3,128 @@ import { join } from 'path';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
import type { CreateServerData } from '../types/server';
|
||||
import type { Prisma } from '../../prisma/generated/prisma/client';
|
||||
|
||||
// Type definitions based on Prisma schema
|
||||
type Server = {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
user: string;
|
||||
password: string | null;
|
||||
auth_type: string | null;
|
||||
ssh_key: string | null;
|
||||
ssh_key_passphrase: string | null;
|
||||
ssh_port: number | null;
|
||||
color: string | null;
|
||||
created_at: Date | null;
|
||||
updated_at: Date | null;
|
||||
ssh_key_path: string | null;
|
||||
key_generated: boolean | null;
|
||||
};
|
||||
|
||||
type InstalledScript = {
|
||||
id: number;
|
||||
script_name: string;
|
||||
script_path: string;
|
||||
container_id: string | null;
|
||||
server_id: number | null;
|
||||
execution_mode: string;
|
||||
installation_date: Date | null;
|
||||
status: string;
|
||||
output_log: string | null;
|
||||
web_ui_ip: string | null;
|
||||
web_ui_port: number | null;
|
||||
};
|
||||
|
||||
type InstalledScriptWithServer = InstalledScript & {
|
||||
server: Server | null;
|
||||
};
|
||||
|
||||
type LXCConfig = {
|
||||
id: number;
|
||||
installed_script_id: number;
|
||||
arch: string | null;
|
||||
cores: number | null;
|
||||
memory: number | null;
|
||||
hostname: string | null;
|
||||
swap: number | null;
|
||||
onboot: number | null;
|
||||
ostype: string | null;
|
||||
unprivileged: number | null;
|
||||
net_name: string | null;
|
||||
net_bridge: string | null;
|
||||
net_hwaddr: string | null;
|
||||
net_ip_type: string | null;
|
||||
net_ip: string | null;
|
||||
net_gateway: string | null;
|
||||
net_type: string | null;
|
||||
net_vlan: number | null;
|
||||
rootfs_storage: string | null;
|
||||
rootfs_size: string | null;
|
||||
feature_keyctl: number | null;
|
||||
feature_nesting: number | null;
|
||||
feature_fuse: number | null;
|
||||
feature_mount: string | null;
|
||||
tags: string | null;
|
||||
advanced_config: string | null;
|
||||
synced_at: Date | null;
|
||||
config_hash: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
};
|
||||
|
||||
type Backup = {
|
||||
id: number;
|
||||
container_id: string;
|
||||
server_id: number;
|
||||
hostname: string;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size: bigint | null;
|
||||
created_at: Date | null;
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
};
|
||||
|
||||
type BackupWithServer = Backup & {
|
||||
server: Server | null;
|
||||
};
|
||||
|
||||
type PBSStorageCredential = {
|
||||
id: number;
|
||||
server_id: number;
|
||||
storage_name: string;
|
||||
pbs_ip: string;
|
||||
pbs_datastore: string;
|
||||
pbs_password: string;
|
||||
pbs_fingerprint: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
};
|
||||
|
||||
type LXCConfigInput = Partial<Omit<LXCConfig, 'id' | 'installed_script_id' | 'created_at' | 'updated_at'>>;
|
||||
|
||||
class DatabaseServicePrisma {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Ensure data/ssh-keys directory exists
|
||||
init(): void {
|
||||
// Ensure data/ssh-keys directory exists (recursive to create parent dirs)
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
if (!existsSync(sshKeysDir)) {
|
||||
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
// Server CRUD operations
|
||||
async createServer(serverData: CreateServerData) {
|
||||
async createServer(serverData: CreateServerData): Promise<Server> {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22;
|
||||
|
||||
let ssh_key_path = null;
|
||||
let ssh_key_path: string | null = null;
|
||||
|
||||
// If using SSH key authentication, create persistent key file
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
@@ -30,7 +132,7 @@ class DatabaseServicePrisma {
|
||||
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||
}
|
||||
|
||||
return await prisma.server.create({
|
||||
const result = await prisma.server.create({
|
||||
data: {
|
||||
name,
|
||||
ip,
|
||||
@@ -45,27 +147,30 @@ class DatabaseServicePrisma {
|
||||
color,
|
||||
}
|
||||
});
|
||||
return result as Server;
|
||||
}
|
||||
|
||||
async getAllServers() {
|
||||
return await prisma.server.findMany({
|
||||
async getAllServers(): Promise<Server[]> {
|
||||
const result = await prisma.server.findMany({
|
||||
orderBy: { created_at: 'desc' }
|
||||
});
|
||||
return result as Server[];
|
||||
}
|
||||
|
||||
async getServerById(id: number) {
|
||||
return await prisma.server.findUnique({
|
||||
async getServerById(id: number): Promise<Server | null> {
|
||||
const result = await prisma.server.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
return result as Server | null;
|
||||
}
|
||||
|
||||
async updateServer(id: number, serverData: CreateServerData) {
|
||||
async updateServer(id: number, serverData: CreateServerData): Promise<Server> {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : undefined;
|
||||
|
||||
// Get existing server to check for key changes
|
||||
const existingServer = await this.getServerById(id);
|
||||
let ssh_key_path = existingServer?.ssh_key_path;
|
||||
let ssh_key_path = existingServer?.ssh_key_path ?? null;
|
||||
|
||||
// Handle SSH key changes
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
@@ -101,7 +206,7 @@ class DatabaseServicePrisma {
|
||||
ssh_key_path = null;
|
||||
}
|
||||
|
||||
return await prisma.server.update({
|
||||
const result = await prisma.server.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
@@ -117,9 +222,10 @@ class DatabaseServicePrisma {
|
||||
color,
|
||||
}
|
||||
});
|
||||
return result as Server;
|
||||
}
|
||||
|
||||
async deleteServer(id: number) {
|
||||
async deleteServer(id: number): Promise<Server> {
|
||||
// Get server info before deletion to clean up key files
|
||||
const server = await this.getServerById(id);
|
||||
|
||||
@@ -136,9 +242,10 @@ class DatabaseServicePrisma {
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.server.delete({
|
||||
const result = await prisma.server.delete({
|
||||
where: { id }
|
||||
});
|
||||
return result as Server;
|
||||
}
|
||||
|
||||
// Installed Scripts CRUD operations
|
||||
@@ -152,10 +259,10 @@ class DatabaseServicePrisma {
|
||||
output_log?: string;
|
||||
web_ui_ip?: string;
|
||||
web_ui_port?: number;
|
||||
}) {
|
||||
}): Promise<InstalledScript> {
|
||||
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
||||
|
||||
return await prisma.installedScript.create({
|
||||
const result = await prisma.installedScript.create({
|
||||
data: {
|
||||
script_name,
|
||||
script_path,
|
||||
@@ -168,34 +275,38 @@ class DatabaseServicePrisma {
|
||||
web_ui_port: web_ui_port ?? null,
|
||||
}
|
||||
});
|
||||
return result as InstalledScript;
|
||||
}
|
||||
|
||||
async getAllInstalledScripts() {
|
||||
return await prisma.installedScript.findMany({
|
||||
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> {
|
||||
const result = await prisma.installedScript.findMany({
|
||||
include: {
|
||||
server: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
return result as InstalledScriptWithServer[];
|
||||
}
|
||||
|
||||
async getInstalledScriptById(id: number) {
|
||||
return await prisma.installedScript.findUnique({
|
||||
async getInstalledScriptById(id: number): Promise<InstalledScriptWithServer | null> {
|
||||
const result = await prisma.installedScript.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
server: true
|
||||
}
|
||||
});
|
||||
return result as InstalledScriptWithServer | null;
|
||||
}
|
||||
|
||||
async getInstalledScriptsByServer(server_id: number) {
|
||||
return await prisma.installedScript.findMany({
|
||||
async getInstalledScriptsByServer(server_id: number): Promise<InstalledScriptWithServer[]> {
|
||||
const result = await prisma.installedScript.findMany({
|
||||
where: { server_id },
|
||||
include: {
|
||||
server: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
return result as InstalledScriptWithServer[];
|
||||
}
|
||||
|
||||
async updateInstalledScript(id: number, updateData: {
|
||||
@@ -205,17 +316,10 @@ class DatabaseServicePrisma {
|
||||
output_log?: string;
|
||||
web_ui_ip?: string;
|
||||
web_ui_port?: number;
|
||||
}) {
|
||||
}): Promise<InstalledScript | { changes: number }> {
|
||||
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
||||
|
||||
const updateFields: {
|
||||
script_name?: string;
|
||||
container_id?: string;
|
||||
status?: 'in_progress' | 'success' | 'failed';
|
||||
output_log?: string;
|
||||
web_ui_ip?: string;
|
||||
web_ui_port?: number;
|
||||
} = {};
|
||||
const updateFields: Prisma.InstalledScriptUpdateInput = {};
|
||||
if (script_name !== undefined) updateFields.script_name = script_name;
|
||||
if (container_id !== undefined) updateFields.container_id = container_id;
|
||||
if (status !== undefined) updateFields.status = status;
|
||||
@@ -227,33 +331,36 @@ class DatabaseServicePrisma {
|
||||
return { changes: 0 };
|
||||
}
|
||||
|
||||
return await prisma.installedScript.update({
|
||||
const result = await prisma.installedScript.update({
|
||||
where: { id },
|
||||
data: updateFields
|
||||
});
|
||||
return result as InstalledScript;
|
||||
}
|
||||
|
||||
async deleteInstalledScript(id: number) {
|
||||
return await prisma.installedScript.delete({
|
||||
async deleteInstalledScript(id: number): Promise<InstalledScript> {
|
||||
const result = await prisma.installedScript.delete({
|
||||
where: { id }
|
||||
});
|
||||
return result as InstalledScript;
|
||||
}
|
||||
|
||||
async deleteInstalledScriptsByServer(server_id: number) {
|
||||
return await prisma.installedScript.deleteMany({
|
||||
async deleteInstalledScriptsByServer(server_id: number): Promise<{ count: number }> {
|
||||
const result = await prisma.installedScript.deleteMany({
|
||||
where: { server_id }
|
||||
});
|
||||
return result as { count: number };
|
||||
}
|
||||
|
||||
async getNextServerId() {
|
||||
async getNextServerId(): Promise<number> {
|
||||
const result = await prisma.server.findFirst({
|
||||
orderBy: { id: 'desc' },
|
||||
select: { id: true }
|
||||
});
|
||||
return (result?.id ?? 0) + 1;
|
||||
return ((result as { id: number } | null)?.id ?? 0) + 1;
|
||||
}
|
||||
|
||||
createSSHKeyFile(serverId: number, sshKey: string) {
|
||||
createSSHKeyFile(serverId: number, sshKey: string): string {
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||
|
||||
@@ -266,17 +373,18 @@ class DatabaseServicePrisma {
|
||||
}
|
||||
|
||||
// LXC Config CRUD operations
|
||||
async createLXCConfig(scriptId: number, configData: any) {
|
||||
return await prisma.lXCConfig.create({
|
||||
async createLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> {
|
||||
const result = await prisma.lXCConfig.create({
|
||||
data: {
|
||||
installed_script_id: scriptId,
|
||||
...configData
|
||||
}
|
||||
});
|
||||
return result as LXCConfig;
|
||||
}
|
||||
|
||||
async updateLXCConfig(scriptId: number, configData: any) {
|
||||
return await prisma.lXCConfig.upsert({
|
||||
async updateLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> {
|
||||
const result = await prisma.lXCConfig.upsert({
|
||||
where: { installed_script_id: scriptId },
|
||||
update: configData,
|
||||
create: {
|
||||
@@ -284,16 +392,18 @@ class DatabaseServicePrisma {
|
||||
...configData
|
||||
}
|
||||
});
|
||||
return result as LXCConfig;
|
||||
}
|
||||
|
||||
async getLXCConfigByScriptId(scriptId: number) {
|
||||
return await prisma.lXCConfig.findUnique({
|
||||
async getLXCConfigByScriptId(scriptId: number): Promise<LXCConfig | null> {
|
||||
const result = await prisma.lXCConfig.findUnique({
|
||||
where: { installed_script_id: scriptId }
|
||||
});
|
||||
return result as LXCConfig | null;
|
||||
}
|
||||
|
||||
async deleteLXCConfig(scriptId: number) {
|
||||
return await prisma.lXCConfig.delete({
|
||||
async deleteLXCConfig(scriptId: number): Promise<void> {
|
||||
await prisma.lXCConfig.delete({
|
||||
where: { installed_script_id: scriptId }
|
||||
});
|
||||
}
|
||||
@@ -309,7 +419,7 @@ class DatabaseServicePrisma {
|
||||
created_at?: Date;
|
||||
storage_name: string;
|
||||
storage_type: 'local' | 'storage' | 'pbs';
|
||||
}) {
|
||||
}): Promise<Backup> {
|
||||
// Find existing backup by container_id, server_id, and backup_path
|
||||
const existing = await prisma.backup.findFirst({
|
||||
where: {
|
||||
@@ -317,11 +427,11 @@ class DatabaseServicePrisma {
|
||||
server_id: backupData.server_id,
|
||||
backup_path: backupData.backup_path,
|
||||
},
|
||||
});
|
||||
}) as Backup | null;
|
||||
|
||||
if (existing) {
|
||||
// Update existing backup
|
||||
return await prisma.backup.update({
|
||||
const result = await prisma.backup.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
hostname: backupData.hostname,
|
||||
@@ -333,9 +443,10 @@ class DatabaseServicePrisma {
|
||||
discovered_at: new Date(),
|
||||
},
|
||||
});
|
||||
return result as Backup;
|
||||
} else {
|
||||
// Create new backup
|
||||
return await prisma.backup.create({
|
||||
const result = await prisma.backup.create({
|
||||
data: {
|
||||
container_id: backupData.container_id,
|
||||
server_id: backupData.server_id,
|
||||
@@ -349,11 +460,12 @@ class DatabaseServicePrisma {
|
||||
discovered_at: new Date(),
|
||||
},
|
||||
});
|
||||
return result as Backup;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllBackups() {
|
||||
return await prisma.backup.findMany({
|
||||
async getAllBackups(): Promise<BackupWithServer[]> {
|
||||
const result = await prisma.backup.findMany({
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
@@ -362,58 +474,43 @@ class DatabaseServicePrisma {
|
||||
{ created_at: 'desc' },
|
||||
],
|
||||
});
|
||||
return result as BackupWithServer[];
|
||||
}
|
||||
|
||||
async getBackupById(id: number) {
|
||||
return await prisma.backup.findUnique({
|
||||
async getBackupById(id: number): Promise<BackupWithServer | null> {
|
||||
const result = await prisma.backup.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
});
|
||||
return result as BackupWithServer | null;
|
||||
}
|
||||
|
||||
async getBackupsByContainerId(containerId: string) {
|
||||
return await prisma.backup.findMany({
|
||||
async getBackupsByContainerId(containerId: string): Promise<BackupWithServer[]> {
|
||||
const result = await prisma.backup.findMany({
|
||||
where: { container_id: containerId },
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
return result as BackupWithServer[];
|
||||
}
|
||||
|
||||
async deleteBackupsForContainer(containerId: string, serverId: number) {
|
||||
return await prisma.backup.deleteMany({
|
||||
async deleteBackupsForContainer(containerId: string, serverId: number): Promise<{ count: number }> {
|
||||
const result = await prisma.backup.deleteMany({
|
||||
where: {
|
||||
container_id: containerId,
|
||||
server_id: serverId,
|
||||
},
|
||||
});
|
||||
return result as { count: number };
|
||||
}
|
||||
|
||||
async getBackupsGroupedByContainer(): Promise<Map<string, Array<{
|
||||
id: number;
|
||||
container_id: string;
|
||||
server_id: number;
|
||||
hostname: string;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size: bigint | null;
|
||||
created_at: Date | null;
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
server: {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
user: string;
|
||||
color: string | null;
|
||||
} | null;
|
||||
}>>> {
|
||||
async getBackupsGroupedByContainer(): Promise<Map<string, BackupWithServer[]>> {
|
||||
const backups = await this.getAllBackups();
|
||||
const grouped = new Map<string, typeof backups>();
|
||||
const grouped = new Map<string, BackupWithServer[]>();
|
||||
|
||||
for (const backup of backups) {
|
||||
const key = backup.container_id;
|
||||
@@ -434,8 +531,8 @@ class DatabaseServicePrisma {
|
||||
pbs_datastore: string;
|
||||
pbs_password: string;
|
||||
pbs_fingerprint: string;
|
||||
}) {
|
||||
return await prisma.pBSStorageCredential.upsert({
|
||||
}): Promise<PBSStorageCredential> {
|
||||
const result = await prisma.pBSStorageCredential.upsert({
|
||||
where: {
|
||||
server_id_storage_name: {
|
||||
server_id: credentialData.server_id,
|
||||
@@ -458,10 +555,11 @@ class DatabaseServicePrisma {
|
||||
pbs_fingerprint: credentialData.pbs_fingerprint,
|
||||
},
|
||||
});
|
||||
return result as PBSStorageCredential;
|
||||
}
|
||||
|
||||
async getPBSCredential(serverId: number, storageName: string) {
|
||||
return await prisma.pBSStorageCredential.findUnique({
|
||||
async getPBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential | null> {
|
||||
const result = await prisma.pBSStorageCredential.findUnique({
|
||||
where: {
|
||||
server_id_storage_name: {
|
||||
server_id: serverId,
|
||||
@@ -469,17 +567,19 @@ class DatabaseServicePrisma {
|
||||
},
|
||||
},
|
||||
});
|
||||
return result as PBSStorageCredential | null;
|
||||
}
|
||||
|
||||
async getPBSCredentialsByServer(serverId: number) {
|
||||
return await prisma.pBSStorageCredential.findMany({
|
||||
async getPBSCredentialsByServer(serverId: number): Promise<PBSStorageCredential[]> {
|
||||
const result = await prisma.pBSStorageCredential.findMany({
|
||||
where: { server_id: serverId },
|
||||
orderBy: { storage_name: 'asc' },
|
||||
});
|
||||
return result as PBSStorageCredential[];
|
||||
}
|
||||
|
||||
async deletePBSCredential(serverId: number, storageName: string) {
|
||||
return await prisma.pBSStorageCredential.delete({
|
||||
async deletePBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential> {
|
||||
const result = await prisma.pBSStorageCredential.delete({
|
||||
where: {
|
||||
server_id_storage_name: {
|
||||
server_id: serverId,
|
||||
@@ -487,9 +587,10 @@ class DatabaseServicePrisma {
|
||||
},
|
||||
},
|
||||
});
|
||||
return result as PBSStorageCredential;
|
||||
}
|
||||
|
||||
async close() {
|
||||
async close(): Promise<void> {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -497,7 +598,7 @@ class DatabaseServicePrisma {
|
||||
// Singleton instance
|
||||
let dbInstance: DatabaseServicePrisma | null = null;
|
||||
|
||||
export function getDatabase() {
|
||||
export function getDatabase(): DatabaseServicePrisma {
|
||||
dbInstance ??= new DatabaseServicePrisma();
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import 'dotenv/config'
|
||||
import { PrismaClient } from '../../prisma/generated/prisma/client.ts'
|
||||
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
||||
|
||||
const globalForPrisma = globalThis;
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL });
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import 'dotenv/config'
|
||||
import { PrismaClient } from '../../prisma/generated/prisma/client'
|
||||
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
const globalForPrisma = globalThis as { prisma?: PrismaClient };
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL! });
|
||||
|
||||
export const prisma: PrismaClient = globalForPrisma.prisma ?? new PrismaClient({
|
||||
adapter,
|
||||
log: ['warn', 'error']
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { AutoSyncService } from '../services/autoSyncService.js';
|
||||
import { repositoryService } from '../services/repositoryService.ts';
|
||||
import { repositoryService } from '../services/repositoryService.js';
|
||||
|
||||
/** @type {AutoSyncService | null} */
|
||||
let autoSyncService = null;
|
||||
let isInitialized = false;
|
||||
|
||||
/**
|
||||
* Initialize default repositories
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function initializeRepositories() {
|
||||
try {
|
||||
console.log('Initializing default repositories...');
|
||||
await repositoryService.initializeDefaultRepositories();
|
||||
console.log('Default repositories initialized successfully');
|
||||
if (repositoryService && repositoryService.initializeDefaultRepositories) {
|
||||
await repositoryService.initializeDefaultRepositories();
|
||||
console.log('Default repositories initialized successfully');
|
||||
} else {
|
||||
console.warn('Repository service not available, skipping repository initialization');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize repositories:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { AutoSyncService } from '~/server/services/autoSyncService';
|
||||
import { repositoryService } from '~/server/services/repositoryService';
|
||||
|
||||
let autoSyncService: AutoSyncService | null = null;
|
||||
|
||||
/**
|
||||
* Initialize default repositories
|
||||
*/
|
||||
export async function initializeRepositories(): Promise<void> {
|
||||
try {
|
||||
console.log('Initializing default repositories...');
|
||||
await repositoryService.initializeDefaultRepositories();
|
||||
console.log('Default repositories initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize repositories:', error);
|
||||
console.error('Error stack:', (error as Error).stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auto-sync service and schedule cron job if enabled
|
||||
*/
|
||||
|
||||
@@ -272,6 +272,12 @@ export class AutoSyncService {
|
||||
|
||||
console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`);
|
||||
|
||||
/** @type {any} */
|
||||
const cronOptions = {
|
||||
scheduled: true,
|
||||
timezone: 'UTC'
|
||||
};
|
||||
|
||||
this.cronJob = cron.schedule(cronExpression, async () => {
|
||||
// Check global lock first
|
||||
if (globalAutoSyncLock) {
|
||||
@@ -300,10 +306,7 @@ export class AutoSyncService {
|
||||
|
||||
console.log('Starting scheduled auto-sync...');
|
||||
await this.executeAutoSync();
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
}, cronOptions);
|
||||
|
||||
console.log('Auto-sync cron job scheduled successfully');
|
||||
}
|
||||
@@ -373,7 +376,7 @@ export class AutoSyncService {
|
||||
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`);
|
||||
|
||||
// Get scripts only for the synced files
|
||||
const localScriptsService = await import('./localScripts.js');
|
||||
const localScriptsService = await import('./localScripts');
|
||||
const syncedScripts = [];
|
||||
|
||||
for (const filename of syncResult.syncedFiles) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/no-unused-vars, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/prefer-optional-chain */
|
||||
import { getSSHExecutionService } from '../ssh-execution-service';
|
||||
import { getStorageService } from './storageService';
|
||||
import { getDatabase } from '../database-prisma';
|
||||
@@ -25,20 +26,20 @@ class BackupService {
|
||||
let hostname = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
void sshService.executeCommand(
|
||||
server,
|
||||
'hostname',
|
||||
(data: string) => {
|
||||
hostname += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(`Failed to get hostname: ${error}`));
|
||||
(_error: string) => {
|
||||
reject(new Error(`Failed to get hostname: ${_error}`));
|
||||
},
|
||||
(exitCode: number) => {
|
||||
if (exitCode === 0) {
|
||||
(_exitCode: number) => {
|
||||
if (_exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`hostname command failed with exit code ${exitCode}`));
|
||||
reject(new Error(`hostname command failed with exit code ${_exitCode}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -61,17 +62,19 @@ class BackupService {
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
void sshService.executeCommand(
|
||||
server,
|
||||
findCommand,
|
||||
(data: string) => {
|
||||
findOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error('Error getting hostname:', error);
|
||||
// Ignore errors - directory might not exist
|
||||
resolve();
|
||||
},
|
||||
(exitCode: number) => {
|
||||
console.error('Error getting find command:', exitCode);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
@@ -96,7 +99,7 @@ class BackupService {
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
void sshService.executeCommand(
|
||||
server,
|
||||
statCommand,
|
||||
(data: string) => {
|
||||
@@ -112,11 +115,11 @@ class BackupService {
|
||||
]);
|
||||
|
||||
const statParts = statOutput.trim().split('|');
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||
|
||||
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
||||
const size = BigInt(statParts[0] || '0');
|
||||
const mtime = parseInt(statParts[1] || '0', 10);
|
||||
const size = BigInt(statParts[0] ?? '0');
|
||||
const mtime = parseInt(statParts[1] ?? '0', 10);
|
||||
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
@@ -144,8 +147,9 @@ class BackupService {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing backup:', error);
|
||||
// Still try to add the backup even if stat fails
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
@@ -182,17 +186,18 @@ class BackupService {
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
void sshService.executeCommand(
|
||||
server,
|
||||
findCommand,
|
||||
(data: string) => {
|
||||
findOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
// Ignore errors - storage might not be mounted
|
||||
console.error('Error getting stat command:', error);
|
||||
resolve();
|
||||
},
|
||||
(exitCode: number) => {
|
||||
console.error('Error getting stat command:', exitCode);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
@@ -218,7 +223,7 @@ class BackupService {
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
void sshService.executeCommand(
|
||||
server,
|
||||
statCommand,
|
||||
(data: string) => {
|
||||
@@ -234,11 +239,11 @@ class BackupService {
|
||||
]);
|
||||
|
||||
const statParts = statOutput.trim().split('|');
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||
|
||||
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
||||
const size = BigInt(statParts[0] || '0');
|
||||
const mtime = parseInt(statParts[1] || '0', 10);
|
||||
const size = BigInt(statParts[0] ?? '0');
|
||||
const mtime = parseInt(statParts[1] ?? '0', 10);
|
||||
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
@@ -270,7 +275,7 @@ class BackupService {
|
||||
} catch (error) {
|
||||
console.error(`Error processing backup ${backupPath}:`, error);
|
||||
// Still try to add the backup even if stat fails
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
const fileName = backupPath.split('/').pop() ?? backupPath;
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
@@ -310,8 +315,8 @@ class BackupService {
|
||||
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
||||
|
||||
// Use IP and datastore from credentials (they override config if different)
|
||||
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
|
||||
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
|
||||
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
|
||||
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
|
||||
|
||||
if (!pbsIp || !pbsDatastore) {
|
||||
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
||||
@@ -339,7 +344,7 @@ class BackupService {
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
void sshService.executeCommand(
|
||||
server,
|
||||
fullCommand,
|
||||
(data: string) => {
|
||||
@@ -405,8 +410,8 @@ class BackupService {
|
||||
|
||||
const storageService = getStorageService();
|
||||
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
||||
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
|
||||
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
|
||||
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip;
|
||||
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore;
|
||||
|
||||
if (!pbsIp || !pbsDatastore) {
|
||||
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
||||
@@ -425,8 +430,8 @@ class BackupService {
|
||||
try {
|
||||
// Add timeout to prevent hanging
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
new Promise<void>((resolve) => {
|
||||
void sshService.executeCommand(
|
||||
server,
|
||||
command,
|
||||
(data: string) => {
|
||||
@@ -468,7 +473,7 @@ class BackupService {
|
||||
if (line.includes('snapshot') && line.includes('size') && line.includes('files')) {
|
||||
continue; // Skip header row
|
||||
}
|
||||
if (line.includes('═') || line.includes('─') || line.includes('│') && line.match(/^[│═─╞╪╡├┼┤└┴┘]+$/)) {
|
||||
if (line.includes('═') || line.includes('─') || line.includes('│') && (/^[│═─╞╪╡├┼┤└┴┘]+$/.exec(line))) {
|
||||
continue; // Skip table separator lines
|
||||
}
|
||||
if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) {
|
||||
@@ -489,7 +494,7 @@ class BackupService {
|
||||
|
||||
// Extract snapshot name (last part after /)
|
||||
const snapshotParts = snapshotPath.split('/');
|
||||
const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath;
|
||||
const snapshotName = snapshotParts[snapshotParts.length - 1] ?? snapshotPath;
|
||||
|
||||
if (!snapshotName) {
|
||||
continue; // Skip if no snapshot name
|
||||
@@ -497,11 +502,12 @@ class BackupService {
|
||||
|
||||
// Parse date from snapshot name (format: 2025-10-21T19:14:55Z)
|
||||
let createdAt: Date | undefined;
|
||||
const dateMatch = snapshotName.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/);
|
||||
if (dateMatch && dateMatch[1]) {
|
||||
const dateMatch = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/.exec(snapshotName);
|
||||
if (dateMatch?.[1]) {
|
||||
try {
|
||||
createdAt = new Date(dateMatch[1]);
|
||||
} catch (e) {
|
||||
console.error('Error parsing date:', e);
|
||||
// Invalid date, leave undefined
|
||||
}
|
||||
}
|
||||
@@ -509,8 +515,8 @@ class BackupService {
|
||||
// Parse size (convert MiB/GiB to bytes)
|
||||
let size: bigint | undefined;
|
||||
if (sizeStr) {
|
||||
const sizeMatch = sizeStr.match(/([\d.]+)\s*(MiB|GiB|KiB|B)/i);
|
||||
if (sizeMatch && sizeMatch[1] && sizeMatch[2]) {
|
||||
const sizeMatch = /([\d.]+)\s*(MiB|GiB|KiB|B)/i.exec(sizeStr);
|
||||
if (sizeMatch?.[1] && sizeMatch[2]) {
|
||||
const sizeValue = parseFloat(sizeMatch[1]);
|
||||
const unit = sizeMatch[2].toUpperCase();
|
||||
let bytes = sizeValue;
|
||||
@@ -640,18 +646,18 @@ class BackupService {
|
||||
if (!script.container_id || !script.server_id || !script.server) continue;
|
||||
|
||||
const containerId = script.container_id;
|
||||
const serverId = script.server_id;
|
||||
const server = script.server as Server;
|
||||
|
||||
try {
|
||||
// Get hostname from LXC config if available, otherwise use script name
|
||||
let hostname = script.script_name || `CT-${script.container_id}`;
|
||||
let hostname = script.script_name ?? `CT-${script.container_id}`;
|
||||
try {
|
||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||
if (lxcConfig?.hostname) {
|
||||
hostname = lxcConfig.hostname;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting LXC config:', error);
|
||||
// LXC config might not exist, use script name
|
||||
console.debug(`No LXC config found for script ${script.id}, using script name as hostname`);
|
||||
}
|
||||
@@ -682,9 +688,7 @@ class BackupService {
|
||||
let backupServiceInstance: BackupService | null = null;
|
||||
|
||||
export function getBackupService(): BackupService {
|
||||
if (!backupServiceInstance) {
|
||||
backupServiceInstance = new BackupService();
|
||||
}
|
||||
backupServiceInstance ??= new BackupService();
|
||||
return backupServiceInstance;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,428 @@
|
||||
// JavaScript wrapper for githubJsonService.ts
|
||||
// This allows the JavaScript autoSyncService.js to import the TypeScript service
|
||||
// JavaScript wrapper for githubJsonService (for use with node server.js)
|
||||
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { repositoryService } from './repositoryService.js';
|
||||
|
||||
import { githubJsonService } from './githubJsonService.ts';
|
||||
// Get environment variables
|
||||
const getEnv = () => ({
|
||||
REPO_BRANCH: process.env.REPO_BRANCH || 'main',
|
||||
JSON_FOLDER: process.env.JSON_FOLDER || 'json',
|
||||
REPO_URL: process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE',
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN
|
||||
});
|
||||
|
||||
export { githubJsonService };
|
||||
class GitHubJsonService {
|
||||
constructor() {
|
||||
this.branch = null;
|
||||
this.jsonFolder = null;
|
||||
this.localJsonDirectory = null;
|
||||
this.scriptCache = new Map();
|
||||
}
|
||||
|
||||
initializeConfig() {
|
||||
if (this.branch === null) {
|
||||
const env = getEnv();
|
||||
this.branch = env.REPO_BRANCH;
|
||||
this.jsonFolder = env.JSON_FOLDER;
|
||||
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json');
|
||||
}
|
||||
}
|
||||
|
||||
getBaseUrl(repoUrl) {
|
||||
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
|
||||
if (!urlMatch) {
|
||||
throw new Error(`Invalid GitHub repository URL: ${repoUrl}`);
|
||||
}
|
||||
|
||||
const [, owner, repo] = urlMatch;
|
||||
return `https://api.github.com/repos/${owner}/${repo}`;
|
||||
}
|
||||
|
||||
extractRepoPath(repoUrl) {
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
|
||||
if (!match) {
|
||||
throw new Error('Invalid GitHub repository URL');
|
||||
}
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
async fetchFromGitHub(repoUrl, endpoint) {
|
||||
const baseUrl = this.getBaseUrl(repoUrl);
|
||||
const env = getEnv();
|
||||
|
||||
const headers = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'PVEScripts-Local/1.0',
|
||||
};
|
||||
|
||||
if (env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${endpoint}`, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
|
||||
error.name = 'RateLimitError';
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async downloadJsonFile(repoUrl, filePath) {
|
||||
this.initializeConfig();
|
||||
const repoPath = this.extractRepoPath(repoUrl);
|
||||
const rawUrl = `https://raw.githubusercontent.com/${repoPath}/${this.branch}/${filePath}`;
|
||||
const env = getEnv();
|
||||
|
||||
const headers = {
|
||||
'User-Agent': 'PVEScripts-Local/1.0',
|
||||
};
|
||||
|
||||
if (env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
const response = await fetch(rawUrl, { headers });
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
const error = new Error(`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN for higher limits.`);
|
||||
error.name = 'RateLimitError';
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
const script = JSON.parse(content);
|
||||
script.repository_url = repoUrl;
|
||||
return script;
|
||||
}
|
||||
|
||||
async getJsonFiles(repoUrl) {
|
||||
this.initializeConfig();
|
||||
|
||||
try {
|
||||
const files = await this.fetchFromGitHub(
|
||||
repoUrl,
|
||||
`/contents/${this.jsonFolder}?ref=${this.branch}`
|
||||
);
|
||||
|
||||
return files.filter(file => file.name.endsWith('.json'));
|
||||
} catch (error) {
|
||||
console.error(`Error fetching JSON files from GitHub (${repoUrl}):`, error);
|
||||
throw new Error(`Failed to fetch script files from repository: ${repoUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getAllScripts(repoUrl) {
|
||||
try {
|
||||
const jsonFiles = await this.getJsonFiles(repoUrl);
|
||||
const scripts = [];
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
try {
|
||||
const script = await this.downloadJsonFile(repoUrl, file.path);
|
||||
scripts.push(script);
|
||||
} catch (error) {
|
||||
console.error(`Failed to download script ${file.name} from ${repoUrl}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return scripts;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching all scripts from ${repoUrl}:`, error);
|
||||
throw new Error(`Failed to fetch scripts from repository: ${repoUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getScriptCards(repoUrl) {
|
||||
try {
|
||||
const scripts = await this.getAllScripts(repoUrl);
|
||||
|
||||
return scripts.map(script => ({
|
||||
name: script.name,
|
||||
slug: script.slug,
|
||||
description: script.description,
|
||||
logo: script.logo,
|
||||
type: script.type,
|
||||
updateable: script.updateable,
|
||||
website: script.website,
|
||||
repository_url: script.repository_url,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Error creating script cards from ${repoUrl}:`, error);
|
||||
throw new Error(`Failed to create script cards from repository: ${repoUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getScriptBySlug(slug, repoUrl) {
|
||||
try {
|
||||
const localScript = await this.getScriptFromLocal(slug);
|
||||
if (localScript) {
|
||||
if (repoUrl && localScript.repository_url !== repoUrl) {
|
||||
return null;
|
||||
}
|
||||
return localScript;
|
||||
}
|
||||
|
||||
if (repoUrl) {
|
||||
try {
|
||||
this.initializeConfig();
|
||||
const script = await this.downloadJsonFile(repoUrl, `${this.jsonFolder}/${slug}.json`);
|
||||
return script;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const enabledRepos = await repositoryService.getEnabledRepositories();
|
||||
for (const repo of enabledRepos) {
|
||||
try {
|
||||
this.initializeConfig();
|
||||
const script = await this.downloadJsonFile(repo.url, `${this.jsonFolder}/${slug}.json`);
|
||||
return script;
|
||||
} catch {
|
||||
// Continue to next repo
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching script by slug:', error);
|
||||
throw new Error(`Failed to fetch script: ${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getScriptFromLocal(slug) {
|
||||
try {
|
||||
if (this.scriptCache.has(slug)) {
|
||||
return this.scriptCache.get(slug);
|
||||
}
|
||||
|
||||
this.initializeConfig();
|
||||
const filePath = join(this.localJsonDirectory, `${slug}.json`);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const script = JSON.parse(content);
|
||||
|
||||
if (!script.repository_url) {
|
||||
const env = getEnv();
|
||||
script.repository_url = env.REPO_URL;
|
||||
}
|
||||
|
||||
this.scriptCache.set(slug, script);
|
||||
|
||||
return script;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async syncJsonFilesForRepo(repoUrl) {
|
||||
try {
|
||||
console.log(`Starting JSON sync from repository: ${repoUrl}`);
|
||||
|
||||
const githubFiles = await this.getJsonFiles(repoUrl);
|
||||
console.log(`Found ${githubFiles.length} JSON files in repository ${repoUrl}`);
|
||||
|
||||
const localFiles = await this.getLocalJsonFiles();
|
||||
console.log(`Found ${localFiles.length} local JSON files`);
|
||||
|
||||
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
|
||||
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
|
||||
|
||||
if (filesToSync.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: `All JSON files are up to date for repository: ${repoUrl}`,
|
||||
count: 0,
|
||||
syncedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`,
|
||||
count: syncedFiles.length,
|
||||
syncedFiles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`JSON sync failed for ${repoUrl}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
count: 0,
|
||||
syncedFiles: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async syncJsonFiles() {
|
||||
try {
|
||||
console.log('Starting multi-repository JSON sync...');
|
||||
|
||||
const enabledRepos = await repositoryService.getEnabledRepositories();
|
||||
|
||||
if (enabledRepos.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No enabled repositories found',
|
||||
count: 0,
|
||||
syncedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Found ${enabledRepos.length} enabled repositories`);
|
||||
|
||||
const allSyncedFiles = [];
|
||||
const processedSlugs = new Set();
|
||||
let totalSynced = 0;
|
||||
|
||||
for (const repo of enabledRepos) {
|
||||
try {
|
||||
console.log(`Syncing from repository: ${repo.url} (priority: ${repo.priority})`);
|
||||
|
||||
const result = await this.syncJsonFilesForRepo(repo.url);
|
||||
|
||||
if (result.success) {
|
||||
const newFiles = result.syncedFiles.filter(file => {
|
||||
const slug = file.replace('.json', '');
|
||||
if (processedSlugs.has(slug)) {
|
||||
return false;
|
||||
}
|
||||
processedSlugs.add(slug);
|
||||
return true;
|
||||
});
|
||||
|
||||
allSyncedFiles.push(...newFiles);
|
||||
totalSynced += newFiles.length;
|
||||
} else {
|
||||
console.error(`Failed to sync from ${repo.url}: ${result.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error syncing from ${repo.url}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateExistingFilesWithRepositoryUrl();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`,
|
||||
count: totalSynced,
|
||||
syncedFiles: allSyncedFiles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Multi-repository JSON sync failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
count: 0,
|
||||
syncedFiles: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async updateExistingFilesWithRepositoryUrl() {
|
||||
try {
|
||||
this.initializeConfig();
|
||||
const files = await this.getLocalJsonFiles();
|
||||
const env = getEnv();
|
||||
const mainRepoUrl = env.REPO_URL;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = join(this.localJsonDirectory, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const script = JSON.parse(content);
|
||||
|
||||
if (!script.repository_url) {
|
||||
script.repository_url = mainRepoUrl;
|
||||
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
|
||||
console.log(`Updated ${file} with repository_url: ${mainRepoUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating ${file}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating existing files with repository_url:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getLocalJsonFiles() {
|
||||
this.initializeConfig();
|
||||
try {
|
||||
const files = await readdir(this.localJsonDirectory);
|
||||
return files.filter(f => f.endsWith('.json'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async findFilesToSyncForRepo(repoUrl, githubFiles, localFiles) {
|
||||
const filesToSync = [];
|
||||
|
||||
for (const ghFile of githubFiles) {
|
||||
const localFilePath = join(this.localJsonDirectory, ghFile.name);
|
||||
|
||||
let needsSync = false;
|
||||
|
||||
if (!localFiles.includes(ghFile.name)) {
|
||||
needsSync = true;
|
||||
} else {
|
||||
try {
|
||||
const content = await readFile(localFilePath, 'utf-8');
|
||||
const script = JSON.parse(content);
|
||||
|
||||
if (!script.repository_url || script.repository_url !== repoUrl) {
|
||||
needsSync = true;
|
||||
}
|
||||
} catch {
|
||||
needsSync = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsSync) {
|
||||
filesToSync.push(ghFile);
|
||||
}
|
||||
}
|
||||
|
||||
return filesToSync;
|
||||
}
|
||||
|
||||
async syncSpecificFiles(repoUrl, filesToSync) {
|
||||
this.initializeConfig();
|
||||
const syncedFiles = [];
|
||||
|
||||
await mkdir(this.localJsonDirectory, { recursive: true });
|
||||
|
||||
for (const file of filesToSync) {
|
||||
try {
|
||||
const script = await this.downloadJsonFile(repoUrl, file.path);
|
||||
const filename = `${script.slug}.json`;
|
||||
const filePath = join(this.localJsonDirectory, filename);
|
||||
|
||||
script.repository_url = repoUrl;
|
||||
|
||||
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
|
||||
syncedFiles.push(filename);
|
||||
|
||||
this.scriptCache.delete(script.slug);
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync ${file.name} from ${repoUrl}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return syncedFiles;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const githubJsonService = new GitHubJsonService();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { env } from '../../env.js';
|
||||
import type { Script, ScriptCard, GitHubFile } from '../../types/script';
|
||||
import { repositoryService } from './repositoryService.ts';
|
||||
import { repositoryService } from './repositoryService';
|
||||
|
||||
export class GitHubJsonService {
|
||||
private branch: string | null = null;
|
||||
@@ -64,7 +65,8 @@ export class GitHubJsonService {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
const data = await response.json();
|
||||
return data as T;
|
||||
}
|
||||
|
||||
private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
|
||||
@@ -214,9 +216,7 @@ export class GitHubJsonService {
|
||||
const script = JSON.parse(content) as Script;
|
||||
|
||||
// If script doesn't have repository_url, set it to main repo (for backward compatibility)
|
||||
if (!script.repository_url) {
|
||||
script.repository_url = env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
|
||||
}
|
||||
script.repository_url ??= env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
|
||||
|
||||
// Cache the script
|
||||
this.scriptCache.set(slug, script);
|
||||
@@ -397,7 +397,6 @@ export class GitHubJsonService {
|
||||
const filesToSync: GitHubFile[] = [];
|
||||
|
||||
for (const ghFile of githubFiles) {
|
||||
const slug = ghFile.name.replace('.json', '');
|
||||
const localFilePath = join(this.localJsonDirectory!, ghFile.name);
|
||||
|
||||
let needsSync = false;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
// JavaScript wrapper for localScripts.ts
|
||||
// This allows the JavaScript autoSyncService.js to import the TypeScript service
|
||||
|
||||
import { localScriptsService } from './localScripts.ts';
|
||||
|
||||
export { localScriptsService };
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { readFile, readdir, writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import type { Script, ScriptCard } from '~/types/script';
|
||||
@@ -95,7 +96,7 @@ export class LocalScriptsService {
|
||||
let foundRepo: string | null = null;
|
||||
for (const repo of enabledRepos) {
|
||||
try {
|
||||
const { githubJsonService } = await import('./githubJsonService.js');
|
||||
const { githubJsonService } = await import('./githubJsonService');
|
||||
const repoScript = await githubJsonService.getScriptBySlug(slug, repo.url);
|
||||
if (repoScript) {
|
||||
foundRepo = repo.url;
|
||||
|
||||
216
src/server/services/repositoryService.js
Normal file
216
src/server/services/repositoryService.js
Normal file
@@ -0,0 +1,216 @@
|
||||
// JavaScript wrapper for repositoryService (for use with node server.js)
|
||||
import { prisma } from '../db.js';
|
||||
|
||||
class RepositoryService {
|
||||
/**
|
||||
* Initialize default repositories if they don't exist
|
||||
*/
|
||||
async initializeDefaultRepositories() {
|
||||
const mainRepoUrl = 'https://github.com/community-scripts/ProxmoxVE';
|
||||
const devRepoUrl = 'https://github.com/community-scripts/ProxmoxVED';
|
||||
|
||||
// Check if repositories already exist
|
||||
const existingRepos = await prisma.repository.findMany({
|
||||
where: {
|
||||
url: {
|
||||
in: [mainRepoUrl, devRepoUrl]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const existingUrls = new Set(existingRepos.map((r) => r.url));
|
||||
|
||||
// Create main repo if it doesn't exist
|
||||
if (!existingUrls.has(mainRepoUrl)) {
|
||||
await prisma.repository.create({
|
||||
data: {
|
||||
url: mainRepoUrl,
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
is_removable: false,
|
||||
priority: 1
|
||||
}
|
||||
});
|
||||
console.log('Initialized main repository:', mainRepoUrl);
|
||||
}
|
||||
|
||||
// Create dev repo if it doesn't exist
|
||||
if (!existingUrls.has(devRepoUrl)) {
|
||||
await prisma.repository.create({
|
||||
data: {
|
||||
url: devRepoUrl,
|
||||
enabled: false,
|
||||
is_default: true,
|
||||
is_removable: false,
|
||||
priority: 2
|
||||
}
|
||||
});
|
||||
console.log('Initialized dev repository:', devRepoUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all repositories, sorted by priority
|
||||
*/
|
||||
async getAllRepositories() {
|
||||
return await prisma.repository.findMany({
|
||||
orderBy: [
|
||||
{ priority: 'asc' },
|
||||
{ created_at: 'asc' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled repositories, sorted by priority
|
||||
*/
|
||||
async getEnabledRepositories() {
|
||||
return await prisma.repository.findMany({
|
||||
where: {
|
||||
enabled: true
|
||||
},
|
||||
orderBy: [
|
||||
{ priority: 'asc' },
|
||||
{ created_at: 'asc' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository by URL
|
||||
*/
|
||||
async getRepositoryByUrl(url) {
|
||||
return await prisma.repository.findUnique({
|
||||
where: { url }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new repository
|
||||
*/
|
||||
async createRepository(data) {
|
||||
// Validate GitHub URL
|
||||
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
|
||||
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const existing = await this.getRepositoryByUrl(data.url);
|
||||
if (existing) {
|
||||
throw new Error('Repository already exists');
|
||||
}
|
||||
|
||||
// Get max priority for user-added repos
|
||||
const maxPriority = await prisma.repository.aggregate({
|
||||
_max: {
|
||||
priority: true
|
||||
}
|
||||
});
|
||||
|
||||
return await prisma.repository.create({
|
||||
data: {
|
||||
url: data.url,
|
||||
enabled: data.enabled ?? true,
|
||||
is_default: false,
|
||||
is_removable: true,
|
||||
priority: data.priority ?? (maxPriority._max.priority ?? 0) + 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update repository
|
||||
*/
|
||||
async updateRepository(id, data) {
|
||||
// If updating URL, validate it
|
||||
if (data.url) {
|
||||
if (!data.url.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+$/)) {
|
||||
throw new Error('Invalid GitHub repository URL. Format: https://github.com/owner/repo');
|
||||
}
|
||||
|
||||
// Check for duplicates (excluding current repo)
|
||||
const existing = await prisma.repository.findFirst({
|
||||
where: {
|
||||
url: data.url,
|
||||
id: { not: id }
|
||||
}
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error('Repository URL already exists');
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.repository.update({
|
||||
where: { id },
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete repository and associated JSON files
|
||||
*/
|
||||
async deleteRepository(id) {
|
||||
const repo = await prisma.repository.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!repo) {
|
||||
throw new Error('Repository not found');
|
||||
}
|
||||
|
||||
if (!repo.is_removable) {
|
||||
throw new Error('Cannot delete default repository');
|
||||
}
|
||||
|
||||
// Delete associated JSON files
|
||||
await this.deleteRepositoryJsonFiles(repo.url);
|
||||
|
||||
// Delete repository
|
||||
await prisma.repository.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all JSON files associated with a repository
|
||||
*/
|
||||
async deleteRepositoryJsonFiles(repoUrl) {
|
||||
const { readdir, unlink, readFile } = await import('fs/promises');
|
||||
const { join } = await import('path');
|
||||
|
||||
const jsonDirectory = join(process.cwd(), 'scripts', 'json');
|
||||
|
||||
try {
|
||||
const files = await readdir(jsonDirectory);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
try {
|
||||
const filePath = join(jsonDirectory, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const script = JSON.parse(content);
|
||||
|
||||
// If script has repository_url matching the repo, delete it
|
||||
if (script.repository_url === repoUrl) {
|
||||
await unlink(filePath);
|
||||
console.log(`Deleted JSON file: ${file} (from repository: ${repoUrl})`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip files that can't be read or parsed
|
||||
console.error(`Error processing file ${file}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory might not exist, which is fine
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error('Error deleting repository JSON files:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const repositoryService = new RepositoryService();
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '../db.ts';
|
||||
/* eslint-disable @typescript-eslint/prefer-regexp-exec */
|
||||
import { prisma } from '../db';
|
||||
|
||||
export class RepositoryService {
|
||||
/**
|
||||
@@ -17,7 +18,7 @@ export class RepositoryService {
|
||||
}
|
||||
});
|
||||
|
||||
const existingUrls = new Set(existingRepos.map(r => r.url));
|
||||
const existingUrls = new Set(existingRepos.map((r: { url: string }) => r.url));
|
||||
|
||||
// Create main repo if it doesn't exist
|
||||
if (!existingUrls.has(mainRepoUrl)) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-optional-chain, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/no-empty-function */
|
||||
import { getSSHExecutionService } from '../ssh-execution-service';
|
||||
import { getBackupService } from './backupService';
|
||||
import { getStorageService } from './storageService';
|
||||
import { getDatabase } from '../database-prisma';
|
||||
import type { Server } from '~/types/server';
|
||||
import type { Storage } from './storageService';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
export interface RestoreProgress {
|
||||
step: string;
|
||||
@@ -76,7 +76,7 @@ class RestoreService {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Try fallback to database
|
||||
try {
|
||||
const installedScripts = await db.getAllInstalledScripts();
|
||||
@@ -90,7 +90,7 @@ class RestoreService {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (dbError) {
|
||||
} catch {
|
||||
// Ignore database error
|
||||
}
|
||||
return null;
|
||||
@@ -231,7 +231,6 @@ class RestoreService {
|
||||
const snapshotNameForPath = snapshotName.replace(/:/g, '_');
|
||||
|
||||
// Determine file extension - try common extensions
|
||||
const extensions = ['.tar', '.tar.zst', '.pxar'];
|
||||
let downloadedPath = '';
|
||||
let downloadSuccess = false;
|
||||
|
||||
@@ -408,7 +407,7 @@ class RestoreService {
|
||||
const clearLogFile = async () => {
|
||||
try {
|
||||
await writeFile(logPath, '', 'utf-8');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore log file errors
|
||||
}
|
||||
};
|
||||
@@ -418,7 +417,7 @@ class RestoreService {
|
||||
try {
|
||||
const logLine = `${message}\n`;
|
||||
await writeFile(logPath, logLine, { flag: 'a', encoding: 'utf-8' });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore log file errors
|
||||
}
|
||||
};
|
||||
@@ -452,10 +451,12 @@ class RestoreService {
|
||||
}
|
||||
|
||||
// Get server details
|
||||
const server = await db.getServerById(serverId);
|
||||
if (!server) {
|
||||
const serverData = await db.getServerById(serverId);
|
||||
if (!serverData) {
|
||||
throw new Error(`Server with ID ${serverId} not found`);
|
||||
}
|
||||
// Cast to Server type (Prisma returns nullable fields as null, Server uses undefined)
|
||||
const server = serverData as unknown as Server;
|
||||
|
||||
// Get rootfs storage
|
||||
await addProgress('reading_config', 'Reading container configuration...');
|
||||
@@ -489,7 +490,7 @@ class RestoreService {
|
||||
await addProgress('stopping', 'Stopping container...');
|
||||
try {
|
||||
await this.stopContainer(server, containerId);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Continue even if stop fails
|
||||
}
|
||||
|
||||
@@ -497,7 +498,7 @@ class RestoreService {
|
||||
await addProgress('destroying', 'Destroying container...');
|
||||
try {
|
||||
await this.destroyContainer(server, containerId);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Container might not exist, which is fine - continue with restore
|
||||
await addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
|
||||
}
|
||||
@@ -559,3 +560,4 @@ export function getRestoreService(): RestoreService {
|
||||
return restoreServiceInstance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,21 +4,23 @@ import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
|
||||
|
||||
export class ScriptDownloaderService {
|
||||
constructor() {
|
||||
this.scriptsDirectory = null;
|
||||
this.repoUrl = null;
|
||||
/** @type {string} */
|
||||
this.scriptsDirectory = join(process.cwd(), 'scripts');
|
||||
/** @type {string} */
|
||||
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
|
||||
}
|
||||
|
||||
initializeConfig() {
|
||||
if (this.scriptsDirectory === null) {
|
||||
this.scriptsDirectory = join(process.cwd(), 'scripts');
|
||||
// Get REPO_URL from environment or use default
|
||||
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
|
||||
}
|
||||
// Re-initialize if needed (for environment changes)
|
||||
this.scriptsDirectory = join(process.cwd(), 'scripts');
|
||||
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a directory path doesn't contain nested directories with the same name
|
||||
* (e.g., prevents ct/ct or install/install)
|
||||
* @param {string} dirPath - The directory path to validate
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validateDirectoryPath(dirPath) {
|
||||
const normalizedPath = dirPath.replace(/\\/g, '/');
|
||||
@@ -36,6 +38,9 @@ export class ScriptDownloaderService {
|
||||
|
||||
/**
|
||||
* Validates that finalTargetDir doesn't contain nested directory names like ct/ct or install/install
|
||||
* @param {string} targetDir - The base target directory
|
||||
* @param {string} finalTargetDir - The final target directory to validate
|
||||
* @returns {string}
|
||||
*/
|
||||
validateTargetDir(targetDir, finalTargetDir) {
|
||||
// Check if finalTargetDir contains nested directory names
|
||||
@@ -53,6 +58,11 @@ export class ScriptDownloaderService {
|
||||
return finalTargetDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists, creating it if necessary
|
||||
* @param {string} dirPath - The directory path to ensure exists
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ensureDirectoryExists(dirPath) {
|
||||
// Validate the directory path to prevent nested directories with the same name
|
||||
this.validateDirectoryPath(dirPath);
|
||||
@@ -61,7 +71,7 @@ export class ScriptDownloaderService {
|
||||
console.log(`[Directory Creation] Ensuring directory exists: ${dirPath}`);
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
console.log(`[Directory Creation] Directory created/verified: ${dirPath}`);
|
||||
} catch (error) {
|
||||
} catch (/** @type {any} */ error) {
|
||||
if (error.code !== 'EEXIST') {
|
||||
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, error.message);
|
||||
throw error;
|
||||
@@ -71,6 +81,11 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract repository path from GitHub URL
|
||||
* @param {string} repoUrl - The GitHub repository URL
|
||||
* @returns {string}
|
||||
*/
|
||||
extractRepoPath(repoUrl) {
|
||||
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
|
||||
if (!match) {
|
||||
@@ -79,6 +94,13 @@ export class ScriptDownloaderService {
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from GitHub
|
||||
* @param {string} repoUrl - The GitHub repository URL
|
||||
* @param {string} filePath - The file path within the repository
|
||||
* @param {string} [branch] - The branch to download from
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async downloadFileFromGitHub(repoUrl, filePath, branch = 'main') {
|
||||
this.initializeConfig();
|
||||
if (!repoUrl) {
|
||||
@@ -88,6 +110,7 @@ export class ScriptDownloaderService {
|
||||
const repoPath = this.extractRepoPath(repoUrl);
|
||||
const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`;
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const headers = {
|
||||
'User-Agent': 'PVEScripts-Local/1.0',
|
||||
};
|
||||
@@ -106,6 +129,11 @@ export class ScriptDownloaderService {
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository URL for a script
|
||||
* @param {import('~/types/script').Script} script - The script object
|
||||
* @returns {string}
|
||||
*/
|
||||
getRepoUrlForScript(script) {
|
||||
// Use repository_url from script if available, otherwise fallback to env or default
|
||||
if (script.repository_url) {
|
||||
@@ -115,6 +143,11 @@ export class ScriptDownloaderService {
|
||||
return this.repoUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify script content to use local paths
|
||||
* @param {string} content - The script content
|
||||
* @returns {string}
|
||||
*/
|
||||
modifyScriptContent(content) {
|
||||
// Replace the build.func source line
|
||||
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
|
||||
@@ -123,9 +156,15 @@ export class ScriptDownloaderService {
|
||||
return content.replace(oldPattern, newPattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a script by downloading its files
|
||||
* @param {import('~/types/script').Script} script - The script to load
|
||||
* @returns {Promise<{success: boolean, message: string, files: string[], error?: string}>}
|
||||
*/
|
||||
async loadScript(script) {
|
||||
this.initializeConfig();
|
||||
try {
|
||||
/** @type {string[]} */
|
||||
const files = [];
|
||||
const repoUrl = this.getRepoUrlForScript(script);
|
||||
const branch = process.env.REPO_BRANCH || 'main';
|
||||
@@ -266,6 +305,11 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a script is downloaded
|
||||
* @param {import('~/types/script').Script} script - The script to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isScriptDownloaded(script) {
|
||||
if (!script.install_methods?.length) return false;
|
||||
|
||||
@@ -318,6 +362,11 @@ export class ScriptDownloaderService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which script files exist locally
|
||||
* @param {import('~/types/script').Script} script - The script to check
|
||||
* @returns {Promise<{ctExists: boolean, installExists: boolean, files: string[]}>}
|
||||
*/
|
||||
async checkScriptExists(script) {
|
||||
this.initializeConfig();
|
||||
const files = [];
|
||||
@@ -416,6 +465,11 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a script's local files
|
||||
* @param {import('~/types/script').Script} script - The script to delete
|
||||
* @returns {Promise<{success: boolean, message: string, deletedFiles: string[]}>}
|
||||
*/
|
||||
async deleteScript(script) {
|
||||
this.initializeConfig();
|
||||
const deletedFiles = [];
|
||||
@@ -467,8 +521,14 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare local script content with remote
|
||||
* @param {import('~/types/script').Script} script - The script to compare
|
||||
* @returns {Promise<{hasDifferences: boolean, differences: string[], error?: string}>}
|
||||
*/
|
||||
async compareScriptContent(script) {
|
||||
this.initializeConfig();
|
||||
/** @type {string[]} */
|
||||
const differences = [];
|
||||
let hasDifferences = false;
|
||||
const repoUrl = this.getRepoUrlForScript(script);
|
||||
@@ -519,13 +579,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 +604,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 +633,16 @@ export class ScriptDownloaderService {
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath)
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
|
||||
}
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
.catch((error) => {
|
||||
console.error(`[Comparison] Promise error for ${alpineInstallScriptPath}:`, error);
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
@@ -584,29 +653,42 @@ export class ScriptDownloaderService {
|
||||
// Wait for all comparisons to complete
|
||||
await Promise.all(comparisonPromises);
|
||||
|
||||
console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`);
|
||||
return { hasDifferences, differences };
|
||||
} catch (error) {
|
||||
console.error('Error comparing script content:', error);
|
||||
return { hasDifferences: false, differences: [] };
|
||||
} catch (/** @type {any} */ error) {
|
||||
console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error);
|
||||
return { hasDifferences: false, differences: [], error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a single file with remote
|
||||
* @param {import('~/types/script').Script} script - The script object
|
||||
* @param {string} remotePath - The remote file path
|
||||
* @param {string} filePath - The local file path
|
||||
* @returns {Promise<{hasDifferences: boolean, filePath: string, error?: string}>}
|
||||
*/
|
||||
async compareSingleFile(script, remotePath, filePath) {
|
||||
try {
|
||||
const localPath = join(this.scriptsDirectory, filePath);
|
||||
const repoUrl = this.getRepoUrlForScript(script);
|
||||
const branch = process.env.REPO_BRANCH || 'main';
|
||||
|
||||
console.log(`[Comparison] Comparing ${filePath} from ${repoUrl} (branch: ${branch})`);
|
||||
|
||||
// Read local content
|
||||
const localContent = await readFile(localPath, 'utf-8');
|
||||
console.log(`[Comparison] Local file size: ${localContent.length} bytes`);
|
||||
|
||||
// Download remote content from the script's repository
|
||||
const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch);
|
||||
console.log(`[Comparison] Remote file size: ${remoteContent.length} bytes`);
|
||||
|
||||
// Apply modification only for CT scripts, not for other script types
|
||||
let modifiedRemoteContent;
|
||||
if (remotePath.startsWith('ct/')) {
|
||||
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
|
||||
console.log(`[Comparison] Applied CT script modifications`);
|
||||
} else {
|
||||
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
|
||||
}
|
||||
@@ -614,13 +696,26 @@ export class ScriptDownloaderService {
|
||||
// Compare content
|
||||
const hasDifferences = localContent !== modifiedRemoteContent;
|
||||
|
||||
if (hasDifferences) {
|
||||
console.log(`[Comparison] Differences found in ${filePath}`);
|
||||
} else {
|
||||
console.log(`[Comparison] No differences in ${filePath}`);
|
||||
}
|
||||
|
||||
return { hasDifferences, filePath };
|
||||
} catch (error) {
|
||||
console.error(`Error comparing file ${filePath}:`, error);
|
||||
return { hasDifferences: false, filePath };
|
||||
} catch (/** @type {any} */ error) {
|
||||
console.error(`[Comparison] Error comparing file ${filePath}:`, error.message);
|
||||
// Return error information so it can be handled upstream
|
||||
return { hasDifferences: false, filePath, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff between local and remote script
|
||||
* @param {import('~/types/script').Script} script - The script object
|
||||
* @param {string} filePath - The file path to diff
|
||||
* @returns {Promise<{diff: string|null, localContent: string|null, remoteContent: string|null}>}
|
||||
*/
|
||||
async getScriptDiff(script, filePath) {
|
||||
this.initializeConfig();
|
||||
try {
|
||||
@@ -680,6 +775,12 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple line-by-line diff
|
||||
* @param {string} localContent - The local file content
|
||||
* @param {string} remoteContent - The remote file content
|
||||
* @returns {string}
|
||||
*/
|
||||
generateDiff(localContent, remoteContent) {
|
||||
const localLines = localContent.split('\n');
|
||||
const remoteLines = remoteContent.split('\n');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises, @typescript-eslint/prefer-optional-chain, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/prefer-regexp-exec, @typescript-eslint/prefer-for-of */
|
||||
import { getSSHExecutionService } from '../ssh-execution-service';
|
||||
import type { Server } from '~/types/server';
|
||||
|
||||
@@ -28,8 +29,7 @@ class StorageService {
|
||||
|
||||
let currentStorage: Partial<Storage> | null = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const rawLine = lines[i];
|
||||
for (const rawLine of lines) {
|
||||
if (!rawLine) continue;
|
||||
|
||||
// Check if line is indented (has leading whitespace/tabs) BEFORE trimming
|
||||
@@ -44,10 +44,10 @@ class StorageService {
|
||||
// Check if this is a storage definition line (format: "type: name")
|
||||
// Storage definitions are NOT indented
|
||||
if (!isIndented) {
|
||||
const storageMatch = line.match(/^(\w+):\s*(.+)$/);
|
||||
if (storageMatch && storageMatch[1] && storageMatch[2]) {
|
||||
const storageMatch = /^(\w+):\s*(.+)$/.exec(line);
|
||||
if (storageMatch?.[1] && storageMatch[2]) {
|
||||
// Save previous storage if exists
|
||||
if (currentStorage && currentStorage.name) {
|
||||
if (currentStorage?.name) {
|
||||
storages.push(this.finalizeStorage(currentStorage));
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ class StorageService {
|
||||
// Parse storage properties (indented lines - can be tabs or spaces)
|
||||
if (currentStorage && isIndented) {
|
||||
// Split on first whitespace (space or tab) to separate key and value
|
||||
const match = line.match(/^(\S+)\s+(.+)$/);
|
||||
const match = /^(\S+)\s+(.+)$/.exec(line);
|
||||
|
||||
if (match && match[1] && match[2]) {
|
||||
if (match?.[1] && match[2]) {
|
||||
const key = match[1];
|
||||
const value = match[2].trim();
|
||||
|
||||
@@ -92,7 +92,7 @@ class StorageService {
|
||||
}
|
||||
|
||||
// Don't forget the last storage
|
||||
if (currentStorage && currentStorage.name) {
|
||||
if (currentStorage?.name) {
|
||||
storages.push(this.finalizeStorage(currentStorage));
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ class StorageService {
|
||||
return {
|
||||
name: storage.name!,
|
||||
type: storage.type!,
|
||||
content: storage.content || [],
|
||||
supportsBackup: storage.supportsBackup || false,
|
||||
content: storage.content ?? [],
|
||||
supportsBackup: storage.supportsBackup ?? false,
|
||||
nodes: storage.nodes,
|
||||
...Object.fromEntries(
|
||||
Object.entries(storage).filter(([key]) =>
|
||||
@@ -138,7 +138,7 @@ class StorageService {
|
||||
let configContent = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
void sshService.executeCommand(
|
||||
server,
|
||||
'cat /etc/pve/storage.cfg',
|
||||
(data: string) => {
|
||||
@@ -191,8 +191,8 @@ class StorageService {
|
||||
}
|
||||
|
||||
return {
|
||||
pbs_ip: (storage as any).server || null,
|
||||
pbs_datastore: (storage as any).datastore || null,
|
||||
pbs_ip: (storage as any).server ?? null,
|
||||
pbs_datastore: (storage as any).datastore ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,9 +215,7 @@ class StorageService {
|
||||
let storageServiceInstance: StorageService | null = null;
|
||||
|
||||
export function getStorageService(): StorageService {
|
||||
if (!storageServiceInstance) {
|
||||
storageServiceInstance = new StorageService();
|
||||
}
|
||||
storageServiceInstance ??= new StorageService();
|
||||
return storageServiceInstance;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
59
update.sh
59
update.sh
@@ -851,6 +851,59 @@ rollback() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check installed Node.js version and upgrade if needed
|
||||
check_node_version() {
|
||||
if ! command -v node &>/dev/null; then
|
||||
log_error "Node.js is not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local current major_version
|
||||
|
||||
current=$(node -v 2>/dev/null | tr -d 'v')
|
||||
major_version=${current%%.*}
|
||||
|
||||
log "Detected Node.js version: $current"
|
||||
|
||||
if (( major_version < 24 )); then
|
||||
log_warning "Node.js < 24 detected → upgrading to Node.js 24 LTS..."
|
||||
upgrade_node_to_24
|
||||
elif (( major_version > 24 )); then
|
||||
log_warning "Node.js > 24 detected → script tested only up to Node 24"
|
||||
log "Continuing anyway…"
|
||||
else
|
||||
log_success "Node.js 24 already installed"
|
||||
fi
|
||||
}
|
||||
|
||||
# Upgrade Node.js to version 24
|
||||
upgrade_node_to_24() {
|
||||
log "Preparing Node.js 24 upgrade…"
|
||||
|
||||
# Remove old nodesource repo if it exists
|
||||
if [ -f /etc/apt/sources.list.d/nodesource.list ]; then
|
||||
rm -f /etc/apt/sources.list.d/nodesource.list
|
||||
fi
|
||||
|
||||
# Install NodeSource repo for Node.js 24
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/node24_setup.sh
|
||||
if ! bash /tmp/node24_setup.sh > /tmp/node24_setup.log 2>&1; then
|
||||
log_error "Failed to configure Node.js 24 repository"
|
||||
tail -20 /tmp/node24_setup.log | while read -r line; do log_error "$line"; done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Installing Node.js 24…"
|
||||
if ! apt-get install -y nodejs >> "$LOG_FILE" 2>&1; then
|
||||
log_error "Failed to install Node.js 24"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local new_ver
|
||||
new_ver=$(node -v 2>/dev/null || true)
|
||||
log_success "Node.js successfully upgraded to $new_ver"
|
||||
}
|
||||
|
||||
# Main update process
|
||||
main() {
|
||||
# Check if this is the relocated/detached version first
|
||||
@@ -913,6 +966,12 @@ main() {
|
||||
|
||||
# Stop the application before updating
|
||||
stop_application
|
||||
|
||||
# Check Node.js version
|
||||
check_node_version
|
||||
|
||||
#Update Node.js to 24
|
||||
upgrade_node_to_24
|
||||
|
||||
# Download and extract release
|
||||
local source_dir
|
||||
|
||||
Reference in New Issue
Block a user