Compare commits

..

11 Commits

Author SHA1 Message Date
Michel Roegl-Brunner
b52188083a Add update confirmation modal with changelog display
- Add UpdateConfirmationModal component that shows changelog before update
- Modify getVersionStatus to include release body (changelog) in response
- Update VersionDisplay to show confirmation modal instead of starting update directly
- Users must review changelog and click 'Proceed with Update' to start update
- Ensures users see potential breaking changes before updating
2025-11-26 10:20:06 +01:00
Michel Roegl-Brunner
53df7201d6 Fix critical bug: prevent reloads from stale updateLogsData.isComplete
- Add isUpdating guard before processing updateLogsData.isComplete
- Reset shouldSubscribe when update completes or fails
- Prevent stale isComplete data from triggering reloads during normal usage
2025-11-26 10:17:57 +01:00
Michel Roegl-Brunner
65bed15722 Fix random page reloads during normal app usage
- Memoize startReconnectAttempts with useCallback to prevent recreation on every render
- Fix useEffect dependency arrays to include memoized function
- Add stricter guards checking refs before starting reconnect attempts
- Ensure reconnect logic only runs when actually updating (not during normal usage)
- Add early return in fallback useEffect to prevent false triggers
- Add ref guards in ResyncButton to prevent multiple simultaneous sync operations
- Only reload after sync if it was user-initiated
2025-11-26 09:05:47 +01:00
Michel Roegl-Brunner
d3da1038db Fix WebSocket upgrade handling to preserve Next.js HMR handlers
- Save existing upgrade listeners before adding our own
- Call existing listeners for non-matching paths to allow Next.js HMR
- Only handle /ws/script-execution ourselves
- This ensures Next.js can handle its own WebSocket upgrades for HMR
2025-11-26 09:01:29 +01:00
Michel Roegl-Brunner
3d45e6d355 Revert WebSocket handling to simpler approach
- Go back to attaching WebSocketServer directly with path option
- Remove manual upgrade event handling that was causing errors
- The path option should filter to only /ws/script-execution
- Next.js should handle its own HMR WebSocket upgrades naturally
2025-11-26 09:00:25 +01:00
Michel Roegl-Brunner
b01c029b18 Fix WebSocket upgrade handling to properly route Next.js HMR
- Create WebSocketServer with noServer: true to avoid auto-attaching
- Manually handle upgrade events to route /ws/script-execution to our WebSocketServer
- Route all other WebSocket upgrades (including Next.js HMR) to Next.js handler
- This ensures Next.js HMR WebSocket connections are properly handled
- Fixes 400 errors for /_next/webpack-hmr WebSocket connections
2025-11-26 08:59:33 +01:00
Michel Roegl-Brunner
c77cd33019 Fix Next.js HMR WebSocket and static asset handling
- Add WebSocket upgrade detection to only intercept /ws/script-execution
- Pass all other WebSocket upgrades (including HMR) to Next.js handler
- Ensure _next routes and static assets are properly handled by Next.js
- Fixes 400 errors for Next.js HMR WebSocket connections
- Fixes 403 errors for static assets by ensuring proper routing
2025-11-26 08:57:45 +01:00
Michel Roegl-Brunner
c8825dddf9 Fix intermittent page reloads from VersionDisplay reconnect logic
- Add guards to prevent reload when not updating
- Use refs to track isUpdating and isNetworkError state in interval callbacks
- Add hasReloadedRef flag to prevent multiple reloads
- Clear reconnect interval when update completes or component unmounts
- Only start reconnect attempts when actually updating
- Prevents false positive reloads when server responds normally
2025-11-26 08:53:00 +01:00
Michel Roegl-Brunner
cd51945d27 Filter categories to only show those with scripts
- Add filter to exclude categories with count 0 from category sidebar
- Only categories with at least one script will be displayed
- Reduces UI clutter by hiding empty categories
2025-11-26 08:50:50 +01:00
Michel Roegl-Brunner
1d36f760a7 Fix downloaded scripts count to include vm/ and tools/ scripts
- Update matching logic to use same robust approach as DownloadedScriptsTab
- Add normalized slug matching to handle filename-based slugs vs JSON slugs
- Add multiple fallback matching strategies for better script detection
- Fixes issue where scripts in vm/ and tools/ directories weren't being counted
2025-11-26 08:48:23 +01:00
Michel Roegl-Brunner
d50aff366c Fix script viewer to support vm/ and tools/ scripts
- Update ScriptDetailModal to extract scriptName from any path (ct/, vm/, tools/)
- Refactor TextViewer to use actual script paths from install_methods
- Remove hardcoded path assumptions and use dynamic script paths
- Only show Install Script tab for ct/ scripts that have install scripts
- Rename CT Script tab to Script for better clarity
2025-11-26 08:47:07 +01:00
64 changed files with 4982 additions and 10244 deletions

3
.gitignore vendored
View File

@@ -16,9 +16,6 @@
db.sqlite db.sqlite
data/settings.db data/settings.db
# prisma generated client
/prisma/generated/
# ssh keys (sensitive) # ssh keys (sensitive)
data/ssh-keys/ data/ssh-keys/

View File

@@ -1 +1 @@
0.5.0 0.4.13

View File

@@ -1,23 +1,15 @@
import eslintPluginNext from "@next/eslint-plugin-next"; import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks"; const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config( export default tseslint.config(
{ {
ignores: [".next", "next-env.d.ts", "postcss.config.js", "prettier.config.js"], ignores: [".next"],
},
{
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"], files: ["**/*.ts", "**/*.tsx"],
extends: [ extends: [

View File

@@ -18,20 +18,30 @@ const config = {
}, },
], ],
}, },
// Allow cross-origin requests from local network in dev mode // Allow cross-origin requests from local network ranges
// Note: In Next.js 16, we disable this check entirely for dev allowedDevOrigins: [
async headers() { 'http://localhost:3000',
return [ 'http://127.0.0.1:3000',
{ 'http://[::1]:3000',
source: '/:path*', 'http://10.*',
headers: [ 'http://172.16.*',
{ key: 'Access-Control-Allow-Origin', value: '*' }, 'http://172.17.*',
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' }, 'http://172.18.*',
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' }, '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.*',
],
turbopack: { turbopack: {
// Disable Turbopack and use Webpack instead for compatibility // Disable Turbopack and use Webpack instead for compatibility
@@ -53,9 +63,9 @@ const config = {
} }
return config; return config;
}, },
// TypeScript errors will fail the build // Ignore TypeScript errors during build (they can be fixed separately)
typescript: { typescript: {
ignoreBuildErrors: false, ignoreBuildErrors: true,
}, },
}; };

1376
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,20 +4,17 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "prisma generate && next build --webpack", "build": "next build --webpack",
"check": "eslint . && tsc --noEmit", "check": "next lint && tsc --noEmit",
"dev": "next dev --webpack", "dev": "next dev --webpack",
"dev:server": "node --import tsx server.js", "dev:server": "node server.js",
"dev:next": "next dev --webpack", "dev:next": "next dev --webpack",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"generate": "prisma generate", "lint": "next lint",
"lint": "eslint .", "lint:fix": "next lint --fix",
"lint:fix": "eslint --fix .",
"migrate": "prisma migrate dev",
"preview": "next build && next start", "preview": "next build && next start",
"postinstall": "prisma generate", "start": "node server.js",
"start": "node --import tsx server.js",
"test": "vitest", "test": "vitest",
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"test:run": "vitest run", "test:run": "vitest run",
@@ -25,73 +22,71 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-better-sqlite3": "^7.0.1", "@prisma/client": "^6.19.0",
"@prisma/client": "^7.0.1",
"better-sqlite3": "^12.4.6",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.11", "@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.7.2", "@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.7.2", "@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.7.2", "@trpc/server": "^11.6.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"axios": "^1.13.2", "axios": "^1.7.9",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cron-validator": "^1.4.0", "cron-validator": "^1.2.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.555.0", "lucide-react": "^0.554.0",
"next": "^16.0.5", "next": "^16.0.4",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"react": "^19.2.0", "react": "^19.0.0",
"react-dom": "^19.2.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
"refractor": "^5.0.0", "refractor": "^5.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"strip-ansi": "^7.1.2", "strip-ansi": "^7.1.2",
"superjson": "^2.2.6", "superjson": "^2.2.3",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.3.1",
"ws": "^8.18.3", "ws": "^8.18.3",
"zod": "^4.1.13" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.17", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.16",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.8",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/react": "^19.2.7", "@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^4.0.14", "@vitest/coverage-v8": "^4.0.13",
"@vitest/ui": "^4.0.14", "@vitest/ui": "^4.0.13",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.5", "eslint-config-next": "^16.0.4",
"jsdom": "^27.2.0", "jsdom": "^27.2.0",
"postcss": "^8.5.6", "postcss": "^8.5.3",
"prettier": "^3.7.1", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
"prisma": "^7.0.1", "prisma": "^6.19.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3", "typescript": "^5.8.2",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.46.2",
"tsx": "^4.19.4", "vitest": "^4.0.13"
"vitest": "^4.0.14"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"

View File

@@ -1,20 +0,0 @@
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 })
},
},
})

View File

@@ -1,10 +1,10 @@
generator client { generator client {
provider = "prisma-client" provider = "prisma-client-js"
output = "./generated/prisma"
} }
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
url = env("DATABASE_URL")
} }
model InstalledScript { model InstalledScript {

View File

@@ -8,12 +8,9 @@ import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty'; import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js'; import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
import { getDatabase } from './src/server/database-prisma.js'; import { getDatabase } from './src/server/database-prisma.js';
import { initializeAutoSync, initializeRepositories, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js';
import dotenv from 'dotenv'; 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 // Load environment variables from .env file
dotenv.config(); dotenv.config();
// Fallback minimal global error handlers for Node runtime (avoid TS import) // Fallback minimal global error handlers for Node runtime (avoid TS import)
@@ -74,10 +71,7 @@ const handle = app.getRequestHandler();
* @property {ServerInfo} [server] * @property {ServerInfo} [server]
* @property {boolean} [isUpdate] * @property {boolean} [isUpdate]
* @property {boolean} [isShell] * @property {boolean} [isShell]
* @property {boolean} [isBackup]
* @property {string} [containerId] * @property {string} [containerId]
* @property {string} [storage]
* @property {string} [backupStorage]
*/ */
class ScriptExecutionHandler { class ScriptExecutionHandler {
@@ -726,7 +720,7 @@ class ScriptExecutionHandler {
* @param {ServerInfo} server * @param {ServerInfo} server
* @param {Function} [onComplete] - Optional callback when backup completes * @param {Function} [onComplete] - Optional callback when backup completes
*/ */
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = undefined) { startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
const sshService = getSSHExecutionService(); const sshService = getSSHExecutionService();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -838,10 +832,10 @@ class ScriptExecutionHandler {
* @param {string} containerId * @param {string} containerId
* @param {string} executionId * @param {string} executionId
* @param {string} mode * @param {string} mode
* @param {ServerInfo|undefined} server * @param {ServerInfo|null} server
* @param {string} [backupStorage] - Optional storage to backup to before update * @param {string} [backupStorage] - Optional storage to backup to before update
*/ */
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = undefined, backupStorage = undefined) { async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) {
try { try {
// If backup storage is provided, run backup first // If backup storage is provided, run backup first
if (backupStorage && mode === 'ssh' && server) { if (backupStorage && mode === 'ssh' && server) {
@@ -1242,38 +1236,13 @@ app.prepare().then(() => {
console.log(`> Ready on http://${hostname}:${port}`); console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`); 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 // Initialize default repositories
if (typeof autoSyncModule.initializeRepositories === 'function') { await initializeRepositories();
console.log('Calling initializeRepositories...');
await autoSyncModule.initializeRepositories();
} else {
console.warn('initializeRepositories is not a function, type:', typeof autoSyncModule.initializeRepositories);
}
// Initialize auto-sync service // Initialize auto-sync service
if (typeof autoSyncModule.initializeAutoSync === 'function') { initializeAutoSync();
console.log('Calling initializeAutoSync...');
autoSyncModule.initializeAutoSync();
}
// Setup graceful shutdown handlers // Setup graceful shutdown handlers
if (typeof autoSyncModule.setupGracefulShutdown === 'function') { setupGracefulShutdown();
console.log('Setting up graceful shutdown...');
autoSyncModule.setupGracefulShutdown();
}
}); });
}); });

View File

@@ -1,13 +1,6 @@
"use client"; 'use client';
import { import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
interface AuthContextType { interface AuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -34,13 +27,10 @@ export function AuthProvider({ children }: AuthProviderProps) {
const checkAuthInternal = async (retryCount = 0) => { const checkAuthInternal = async (retryCount = 0) => {
try { try {
// First check if setup is completed // 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) { if (setupResponse.ok) {
const setupData = (await setupResponse.json()) as { const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
setupCompleted: boolean;
enabled: boolean;
};
// If setup is not completed or auth is disabled, don't verify // If setup is not completed or auth is disabled, don't verify
if (!setupData.setupCompleted || !setupData.enabled) { if (!setupData.setupCompleted || !setupData.enabled) {
setIsAuthenticated(false); setIsAuthenticated(false);
@@ -52,12 +42,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
} }
// Only verify authentication if setup is completed and auth is enabled // Only verify authentication if setup is completed and auth is enabled
const response = await fetch("/api/auth/verify", { const response = await fetch('/api/auth/verify', {
credentials: "include", // Ensure cookies are sent credentials: 'include', // Ensure cookies are sent
}); });
if (response.ok) { if (response.ok) {
const data = (await response.json()) as { const data = await response.json() as {
username: string; username: string;
expirationTime?: number | null; expirationTime?: number | null;
timeUntilExpiration?: number | null; timeUntilExpiration?: number | null;
}; };
@@ -68,7 +58,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
setIsAuthenticated(false); setIsAuthenticated(false);
setUsername(null); setUsername(null);
setExpirationTime(null); setExpirationTime(null);
// Retry logic for failed auth checks (max 2 retries) // Retry logic for failed auth checks (max 2 retries)
if (retryCount < 2) { if (retryCount < 2) {
setTimeout(() => { setTimeout(() => {
@@ -78,11 +68,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
} }
} }
} catch (error) { } catch (error) {
console.error("Error checking auth:", error); console.error('Error checking auth:', error);
setIsAuthenticated(false); setIsAuthenticated(false);
setUsername(null); setUsername(null);
setExpirationTime(null); setExpirationTime(null);
// Retry logic for network errors (max 2 retries) // Retry logic for network errors (max 2 retries)
if (retryCount < 2) { if (retryCount < 2) {
setTimeout(() => { setTimeout(() => {
@@ -99,49 +89,44 @@ export function AuthProvider({ children }: AuthProviderProps) {
return checkAuthInternal(0); return checkAuthInternal(0);
}, []); }, []);
const login = async ( const login = async (username: string, password: string): Promise<boolean> => {
username: string,
password: string,
): Promise<boolean> => {
try { try {
const response = await fetch("/api/auth/login", { const response = await fetch('/api/auth/login', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
credentials: "include", // Ensure cookies are received credentials: 'include', // Ensure cookies are received
}); });
if (response.ok) { if (response.ok) {
const data = (await response.json()) as { const data = await response.json() as { username: string };
username: string;
expirationTime?: number;
};
setIsAuthenticated(true); setIsAuthenticated(true);
setUsername(data.username); setUsername(data.username);
// Set expiration time from login response if available
if (data.expirationTime) { // Check auth again to get expiration time
setExpirationTime(data.expirationTime); // Add a small delay to ensure the httpOnly cookie is available
} await new Promise<void>((resolve) => {
// Don't call checkAuth after login - we already know we're authenticated setTimeout(() => {
// The cookie is set by the server response void checkAuth().then(() => resolve());
}, 150);
});
return true; return true;
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
console.error("Login failed:", errorData.error); console.error('Login failed:', errorData.error);
return false; return false;
} }
} catch (error) { } catch (error) {
console.error("Login error:", error); console.error('Login error:', error);
return false; return false;
} }
}; };
const logout = () => { const logout = () => {
// Clear the auth cookie by setting it to expire // Clear the auth cookie by setting it to expire
document.cookie = document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
"auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
setIsAuthenticated(false); setIsAuthenticated(false);
setUsername(null); setUsername(null);
setExpirationTime(null); setExpirationTime(null);
@@ -171,7 +156,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
export function useAuth() { export function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (context === undefined) { 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; return context;
} }

View File

@@ -1,8 +1,8 @@
"use client"; 'use client';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from 'lucide-react';
import { useRegisterModal } from "./modal/ModalStackProvider"; import { useRegisterModal } from './modal/ModalStackProvider';
interface BackupWarningModalProps { interface BackupWarningModalProps {
isOpen: boolean; isOpen: boolean;
@@ -13,43 +13,33 @@ interface BackupWarningModalProps {
export function BackupWarningModal({ export function BackupWarningModal({
isOpen, isOpen,
onClose, onClose,
onProceed, onProceed
}: BackupWarningModalProps) { }: BackupWarningModalProps) {
useRegisterModal(isOpen, { useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose });
id: "backup-warning-modal",
allowEscape: true,
onClose,
});
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-center border-b p-6"> <div className="flex items-center justify-center p-6 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AlertTriangle className="text-warning h-8 w-8" /> <AlertTriangle className="h-8 w-8 text-warning" />
<h2 className="text-card-foreground text-2xl font-bold"> <h2 className="text-2xl font-bold text-card-foreground">Backup Failed</h2>
Backup Failed
</h2>
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<p className="text-muted-foreground mb-6 text-sm"> <p className="text-sm text-muted-foreground mb-6">
The backup failed, but you can still proceed with the update if you The backup failed, but you can still proceed with the update if you wish.
wish. <br /><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.
<br />
<strong className="text-foreground">Warning:</strong> Proceeding
without a backup means you won&apos;t be able to restore the
container if something goes wrong during the update.
</p> </p>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col justify-end gap-3 sm:flex-row"> <div className="flex flex-col sm:flex-row justify-end gap-3">
<Button <Button
onClick={onClose} onClick={onClose}
variant="outline" variant="outline"
@@ -62,7 +52,7 @@ export function BackupWarningModal({
onClick={onProceed} onClick={onProceed}
variant="default" variant="default"
size="default" size="default"
className="bg-warning hover:bg-warning/90 w-full sm:w-auto" className="w-full sm:w-auto bg-warning hover:bg-warning/90"
> >
Proceed Anyway Proceed Anyway
</Button> </Button>
@@ -72,3 +62,6 @@ export function BackupWarningModal({
</div> </div>
); );
} }

View File

@@ -1,27 +1,18 @@
"use client"; 'use client';
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import { api } from "~/trpc/react"; import { api } from '~/trpc/react';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { Badge } from "./ui/badge"; import { Badge } from './ui/badge';
import { import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react';
RefreshCw,
ChevronDown,
ChevronRight,
HardDrive,
Database,
Server,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "./ui/dropdown-menu"; } from './ui/dropdown-menu';
import { ConfirmationModal } from "./ConfirmationModal"; import { ConfirmationModal } from './ConfirmationModal';
import { LoadingModal } from "./LoadingModal"; import { LoadingModal } from './LoadingModal';
interface Backup { interface Backup {
id: number; id: number;
@@ -32,7 +23,7 @@ interface Backup {
storage_name: string; storage_name: string;
storage_type: string; storage_type: string;
discovered_at: Date; discovered_at: Date;
server_id?: number; server_id: number;
server_name: string | null; server_name: string | null;
server_color: string | null; server_color: string | null;
} }
@@ -44,25 +35,16 @@ interface ContainerBackups {
} }
export function BackupsTab() { export function BackupsTab() {
const [expandedContainers, setExpandedContainers] = useState<Set<string>>( const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set());
new Set(),
);
const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false); const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false);
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false); const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
const [selectedBackup, setSelectedBackup] = useState<{ const [selectedBackup, setSelectedBackup] = useState<{ backup: Backup; containerId: string } | null>(null);
backup: Backup;
containerId: string;
} | null>(null);
const [restoreProgress, setRestoreProgress] = useState<string[]>([]); const [restoreProgress, setRestoreProgress] = useState<string[]>([]);
const [restoreSuccess, setRestoreSuccess] = useState(false); const [restoreSuccess, setRestoreSuccess] = useState(false);
const [restoreError, setRestoreError] = useState<string | null>(null); const [restoreError, setRestoreError] = useState<string | null>(null);
const [shouldPollRestore, setShouldPollRestore] = useState(false); const [shouldPollRestore, setShouldPollRestore] = useState(false);
const { const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
data: backupsData,
refetch: refetchBackups,
isLoading,
} = api.backups.getAllBackupsGrouped.useQuery();
const discoverMutation = api.backups.discoverBackups.useMutation({ const discoverMutation = api.backups.discoverBackups.useMutation({
onSuccess: () => { onSuccess: () => {
void refetchBackups(); void refetchBackups();
@@ -70,30 +52,26 @@ export function BackupsTab() {
}); });
// Poll for restore progress // Poll for restore progress
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery( const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, {
undefined, enabled: shouldPollRestore,
{ refetchInterval: 1000, // Poll every second
enabled: shouldPollRestore, refetchIntervalInBackground: true,
refetchInterval: 1000, // Poll every second });
refetchIntervalInBackground: true,
},
);
// Update restore progress when log data changes // Update restore progress when log data changes
useEffect(() => { useEffect(() => {
if (restoreLogsData?.success && restoreLogsData.logs) { if (restoreLogsData?.success && restoreLogsData.logs) {
setRestoreProgress(restoreLogsData.logs); setRestoreProgress(restoreLogsData.logs);
// Stop polling when restore is complete // Stop polling when restore is complete
if (restoreLogsData.isComplete) { if (restoreLogsData.isComplete) {
setShouldPollRestore(false); setShouldPollRestore(false);
// Check if restore was successful or failed // Check if restore was successful or failed
const lastLog = const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || '';
restoreLogsData.logs[restoreLogsData.logs.length - 1] ?? ""; if (lastLog.includes('Restore completed successfully')) {
if (lastLog.includes("Restore completed successfully")) {
setRestoreSuccess(true); setRestoreSuccess(true);
setRestoreError(null); setRestoreError(null);
} else if (lastLog.includes("Error:") || lastLog.includes("failed")) { } else if (lastLog.includes('Error:') || lastLog.includes('failed')) {
setRestoreError(lastLog); setRestoreError(lastLog);
setRestoreSuccess(false); setRestoreSuccess(false);
} }
@@ -105,22 +83,17 @@ export function BackupsTab() {
onMutate: () => { onMutate: () => {
// Start polling for progress // Start polling for progress
setShouldPollRestore(true); setShouldPollRestore(true);
setRestoreProgress(["Starting restore..."]); setRestoreProgress(['Starting restore...']);
setRestoreError(null); setRestoreError(null);
setRestoreSuccess(false); setRestoreSuccess(false);
}, },
onSuccess: (result) => { onSuccess: (result) => {
// Stop polling - progress will be updated from logs // Stop polling - progress will be updated from logs
setShouldPollRestore(false); setShouldPollRestore(false);
if (result.success) { if (result.success) {
// Update progress with all messages from backend (fallback if polling didn't work) // Update progress with all messages from backend (fallback if polling didn't work)
const progressMessages = const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']);
restoreProgress.length > 0
? restoreProgress
: (result.progress?.map((p) => p.message) ?? [
"Restore completed successfully",
]);
setRestoreProgress(progressMessages); setRestoreProgress(progressMessages);
setRestoreSuccess(true); setRestoreSuccess(true);
setRestoreError(null); setRestoreError(null);
@@ -128,10 +101,8 @@ export function BackupsTab() {
setSelectedBackup(null); setSelectedBackup(null);
// Keep success message visible - user can dismiss manually // Keep success message visible - user can dismiss manually
} else { } else {
setRestoreError(result.error ?? "Restore failed"); setRestoreError(result.error || 'Restore failed');
setRestoreProgress( setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress);
result.progress?.map((p) => p.message) ?? restoreProgress,
);
setRestoreSuccess(false); setRestoreSuccess(false);
setRestoreConfirmOpen(false); setRestoreConfirmOpen(false);
setSelectedBackup(null); setSelectedBackup(null);
@@ -141,25 +112,24 @@ export function BackupsTab() {
onError: (error) => { onError: (error) => {
// Stop polling on error // Stop polling on error
setShouldPollRestore(false); setShouldPollRestore(false);
setRestoreError(error.message ?? "Restore failed"); setRestoreError(error.message || 'Restore failed');
setRestoreConfirmOpen(false); setRestoreConfirmOpen(false);
setSelectedBackup(null); setSelectedBackup(null);
setRestoreProgress([]); setRestoreProgress([]);
}, },
}); });
// Update progress text in modal based on current progress // Update progress text in modal based on current progress
const currentProgressText = const currentProgressText = restoreProgress.length > 0
restoreProgress.length > 0 ? restoreProgress[restoreProgress.length - 1]
? restoreProgress[restoreProgress.length - 1] : 'Restoring backup...';
: "Restoring backup...";
// Auto-discover backups when tab is first opened // Auto-discover backups when tab is first opened
useEffect(() => { useEffect(() => {
if (!hasAutoDiscovered && !isLoading && backupsData) { if (!hasAutoDiscovered && !isLoading && backupsData) {
// Only auto-discover if there are no backups yet // Only auto-discover if there are no backups yet
if (!backupsData.backups?.length) { if (!backupsData.backups || backupsData.backups.length === 0) {
void handleDiscoverBackups(); handleDiscoverBackups();
} }
setHasAutoDiscovered(true); setHasAutoDiscovered(true);
} }
@@ -179,15 +149,15 @@ export function BackupsTab() {
const handleRestoreConfirm = () => { const handleRestoreConfirm = () => {
if (!selectedBackup) return; if (!selectedBackup) return;
setRestoreConfirmOpen(false); setRestoreConfirmOpen(false);
setRestoreError(null); setRestoreError(null);
setRestoreSuccess(false); setRestoreSuccess(false);
restoreMutation.mutate({ restoreMutation.mutate({
backupId: selectedBackup.backup.id, backupId: selectedBackup.backup.id,
containerId: selectedBackup.containerId, containerId: selectedBackup.containerId,
serverId: selectedBackup.backup.server_id ?? 0, serverId: selectedBackup.backup.server_id,
}); });
}; };
@@ -202,41 +172,39 @@ export function BackupsTab() {
}; };
const formatFileSize = (bytes: bigint | null): string => { const formatFileSize = (bytes: bigint | null): string => {
if (!bytes) return "Unknown size"; if (!bytes) return 'Unknown size';
const b = Number(bytes); const b = Number(bytes);
if (b === 0) return "0 B"; if (b === 0) return '0 B';
const k = 1024; 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)); const i = Math.floor(Math.log(b) / Math.log(k));
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}; };
const formatDate = (date: Date | null): string => { const formatDate = (date: Date | null): string => {
if (!date) return "Unknown date"; if (!date) return 'Unknown date';
return new Date(date).toLocaleString(); return new Date(date).toLocaleString();
}; };
const getStorageTypeIcon = (type: string) => { const getStorageTypeIcon = (type: string) => {
switch (type) { switch (type) {
case "pbs": case 'pbs':
return <Database className="h-4 w-4" />; return <Database className="h-4 w-4" />;
case "local": case 'local':
return <HardDrive className="h-4 w-4" />; return <HardDrive className="h-4 w-4" />;
default: default:
return <Server className="h-4 w-4" />; return <Server className="h-4 w-4" />;
} }
}; };
const getStorageTypeBadgeVariant = ( const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => {
type: string,
): "default" | "secondary" | "outline" => {
switch (type) { switch (type) {
case "pbs": case 'pbs':
return "default"; return 'default';
case "local": case 'local':
return "secondary"; return 'secondary';
default: default:
return "outline"; return 'outline';
} }
}; };
@@ -248,8 +216,8 @@ export function BackupsTab() {
{/* Header with refresh button */} {/* Header with refresh button */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-foreground text-2xl font-bold">Backups</h2> <h2 className="text-2xl font-bold text-foreground">Backups</h2>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-sm text-muted-foreground mt-1">
Discovered backups grouped by container ID Discovered backups grouped by container ID
</p> </p>
</div> </div>
@@ -258,38 +226,31 @@ export function BackupsTab() {
disabled={isDiscovering} disabled={isDiscovering}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<RefreshCw <RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} />
className={`h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`} {isDiscovering ? 'Discovering...' : 'Discover Backups'}
/>
{isDiscovering ? "Discovering..." : "Discover Backups"}
</Button> </Button>
</div> </div>
{/* Loading state */} {/* Loading state */}
{(isLoading || isDiscovering) && backups.length === 0 && ( {(isLoading || isDiscovering) && backups.length === 0 && (
<div className="bg-card border-border rounded-lg border p-8 text-center"> <div className="bg-card rounded-lg border border-border p-8 text-center">
<RefreshCw className="text-muted-foreground mx-auto mb-4 h-8 w-8 animate-spin" /> <RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{isDiscovering ? "Discovering backups..." : "Loading backups..."} {isDiscovering ? 'Discovering backups...' : 'Loading backups...'}
</p> </p>
</div> </div>
)} )}
{/* Empty state */} {/* Empty state */}
{!isLoading && !isDiscovering && backups.length === 0 && ( {!isLoading && !isDiscovering && backups.length === 0 && (
<div className="bg-card border-border rounded-lg border p-8 text-center"> <div className="bg-card rounded-lg border border-border p-8 text-center">
<HardDrive className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-foreground mb-2 text-lg font-semibold"> <h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
No backups found
</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
Click &quot;Discover Backups&quot; to scan for backups on your Click "Discover Backups" to scan for backups on your servers.
servers.
</p> </p>
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}> <Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
<RefreshCw <RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
className={`mr-2 h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`}
/>
Discover Backups Discover Backups
</Button> </Button>
</div> </div>
@@ -305,35 +266,33 @@ export function BackupsTab() {
return ( return (
<div <div
key={container.container_id} key={container.container_id}
className="bg-card border-border overflow-hidden rounded-lg border shadow-sm" className="bg-card rounded-lg border border-border shadow-sm overflow-hidden"
> >
{/* Container header - collapsible */} {/* Container header - collapsible */}
<button <button
onClick={() => toggleContainer(container.container_id)} onClick={() => toggleContainer(container.container_id)}
className="hover:bg-accent/50 flex w-full items-center justify-between p-4 text-left transition-colors" className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors text-left"
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex items-center gap-3 flex-1 min-w-0">
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" /> <ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" />
) : ( ) : (
<ChevronRight className="text-muted-foreground h-5 w-5 flex-shrink-0" /> <ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
)} )}
<div className="min-w-0 flex-1"> <div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-foreground font-semibold"> <span className="font-semibold text-foreground">
CT {container.container_id} CT {container.container_id}
</span> </span>
{container.hostname && ( {container.hostname && (
<> <>
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">{container.hostname}</span>
{container.hostname}
</span>
</> </>
)} )}
</div> </div>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-sm text-muted-foreground mt-1">
{backupCount} {backupCount === 1 ? "backup" : "backups"} {backupCount} {backupCount === 1 ? 'backup' : 'backups'}
</p> </p>
</div> </div>
</div> </div>
@@ -341,30 +300,28 @@ export function BackupsTab() {
{/* Container content - backups list */} {/* Container content - backups list */}
{isExpanded && ( {isExpanded && (
<div className="border-border border-t"> <div className="border-t border-border">
<div className="space-y-3 p-4"> <div className="p-4 space-y-3">
{container.backups.map((backup) => ( {container.backups.map((backup) => (
<div <div
key={backup.id} key={backup.id}
className="bg-muted/50 border-border/50 rounded-lg border p-4" className="bg-muted/50 rounded-lg p-4 border border-border/50"
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="flex-1 min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-foreground font-medium break-all"> <span className="font-medium text-foreground break-all">
{backup.backup_name} {backup.backup_name}
</span> </span>
<Badge <Badge
variant={getStorageTypeBadgeVariant( variant={getStorageTypeBadgeVariant(backup.storage_type)}
backup.storage_type,
)}
className="flex items-center gap-1" className="flex items-center gap-1"
> >
{getStorageTypeIcon(backup.storage_type)} {getStorageTypeIcon(backup.storage_type)}
{backup.storage_name} {backup.storage_name}
</Badge> </Badge>
</div> </div>
<div className="text-muted-foreground flex flex-wrap items-center gap-4 text-sm"> <div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
{backup.size && ( {backup.size && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" /> <HardDrive className="h-3 w-3" />
@@ -382,7 +339,7 @@ export function BackupsTab() {
)} )}
</div> </div>
<div className="mt-2"> <div className="mt-2">
<code className="text-muted-foreground text-xs break-all"> <code className="text-xs text-muted-foreground break-all">
{backup.backup_path} {backup.backup_path}
</code> </code>
</div> </div>
@@ -393,19 +350,14 @@ export function BackupsTab() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
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" 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"
> >
Actions Actions
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="bg-card border-border w-48"> <DropdownMenuContent className="w-48 bg-card border-border">
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() => handleRestoreClick(backup, container.container_id)}
handleRestoreClick(
backup,
container.container_id,
)
}
disabled={restoreMutation.isPending} disabled={restoreMutation.isPending}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
> >
@@ -434,9 +386,9 @@ export function BackupsTab() {
{/* Error state */} {/* Error state */}
{backupsData && !backupsData.success && ( {backupsData && !backupsData.success && (
<div className="bg-destructive/10 border-destructive rounded-lg border p-4"> <div className="bg-destructive/10 border border-destructive rounded-lg p-4">
<p className="text-destructive"> <p className="text-destructive">
Error loading backups: {backupsData.error ?? "Unknown error"} Error loading backups: {backupsData.error || 'Unknown error'}
</p> </p>
</div> </div>
)} )}
@@ -460,8 +412,7 @@ export function BackupsTab() {
)} )}
{/* Restore Progress Modal */} {/* Restore Progress Modal */}
{(restoreMutation.isPending || {(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
(restoreSuccess && restoreProgress.length > 0)) && (
<LoadingModal <LoadingModal
isOpen={true} isOpen={true}
action={currentProgressText} action={currentProgressText}
@@ -477,13 +428,11 @@ export function BackupsTab() {
{/* Restore Success */} {/* Restore Success */}
{restoreSuccess && ( {restoreSuccess && (
<div className="bg-success/10 border-success/20 rounded-lg border p-4"> <div className="bg-success/10 border border-success/20 rounded-lg p-4">
<div className="mb-2 flex items-center justify-between"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="text-success h-5 w-5" /> <CheckCircle className="h-5 w-5 text-success" />
<span className="text-success font-medium"> <span className="font-medium text-success">Restore Completed Successfully</span>
Restore Completed Successfully
</span>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -497,7 +446,7 @@ export function BackupsTab() {
× ×
</Button> </Button>
</div> </div>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
The container has been restored from backup. The container has been restored from backup.
</p> </p>
</div> </div>
@@ -505,11 +454,11 @@ export function BackupsTab() {
{/* Restore Error */} {/* Restore Error */}
{restoreError && ( {restoreError && (
<div className="bg-error/10 border-error/20 rounded-lg border p-4"> <div className="bg-error/10 border border-error/20 rounded-lg p-4">
<div className="mb-2 flex items-center justify-between"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="text-error h-5 w-5" /> <AlertCircle className="h-5 w-5 text-error" />
<span className="text-error font-medium">Restore Failed</span> <span className="font-medium text-error">Restore Failed</span>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -523,11 +472,13 @@ export function BackupsTab() {
× ×
</Button> </Button>
</div> </div>
<p className="text-muted-foreground text-sm">{restoreError}</p> <p className="text-sm text-muted-foreground">
{restoreError}
</p>
{restoreProgress.length > 0 && ( {restoreProgress.length > 0 && (
<div className="mt-2 space-y-1"> <div className="space-y-1 mt-2">
{restoreProgress.map((message, index) => ( {restoreProgress.map((message, index) => (
<p key={index} className="text-muted-foreground text-sm"> <p key={index} className="text-sm text-muted-foreground">
{message} {message}
</p> </p>
))} ))}
@@ -549,3 +500,4 @@ export function BackupsTab() {
</div> </div>
); );
} }

View File

@@ -1,53 +1,41 @@
"use client"; 'use client';
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from 'react';
import { api } from "~/trpc/react"; import { api } from '~/trpc/react';
import { ScriptCard } from "./ScriptCard"; import { ScriptCard } from './ScriptCard';
import { ScriptCardList } from "./ScriptCardList"; import { ScriptCardList } from './ScriptCardList';
import { ScriptDetailModal } from "./ScriptDetailModal"; import { ScriptDetailModal } from './ScriptDetailModal';
import { CategorySidebar } from "./CategorySidebar"; import { CategorySidebar } from './CategorySidebar';
import { FilterBar, type FilterState } from "./FilterBar"; import { FilterBar, type FilterState } from './FilterBar';
import { ViewToggle } from "./ViewToggle"; import { ViewToggle } from './ViewToggle';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import type { ScriptCard as ScriptCardType } from "~/types/script"; import type { ScriptCard as ScriptCardType } from '~/types/script';
import type { Server } from "~/types/server"; import { getDefaultFilters, mergeFiltersWithDefaults } from './filterUtils';
import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils";
interface DownloadedScriptsTabProps { interface DownloadedScriptsTabProps {
onInstallScript?: ( onInstallScript?: (
scriptPath: string, scriptPath: string,
scriptName: string, scriptName: string,
mode?: "local" | "ssh", mode?: "local" | "ssh",
server?: Server, server?: any,
) => void; ) => void;
} }
export function DownloadedScriptsTab({ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
onInstallScript,
}: DownloadedScriptsTabProps) {
const [selectedSlug, setSelectedSlug] = useState<string | null>(null); const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); 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 [filters, setFilters] = useState<FilterState>(getDefaultFilters());
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false); const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true); const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const { const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
data: scriptCardsData, const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
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( const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
{ slug: selectedSlug ?? "" }, { slug: selectedSlug ?? '' },
{ enabled: !!selectedSlug }, { enabled: !!selectedSlug }
); );
// Load SAVE_FILTER setting, saved filters, and view mode on component mount // Load SAVE_FILTER setting, saved filters, and view mode on component mount
@@ -55,7 +43,7 @@ export function DownloadedScriptsTab({
const loadSettings = async () => { const loadSettings = async () => {
try { try {
// Load SAVE_FILTER setting // Load SAVE_FILTER setting
const saveFilterResponse = await fetch("/api/settings/save-filter"); const saveFilterResponse = await fetch('/api/settings/save-filter');
let saveFilterEnabled = false; let saveFilterEnabled = false;
if (saveFilterResponse.ok) { if (saveFilterResponse.ok) {
const saveFilterData = await saveFilterResponse.json(); const saveFilterData = await saveFilterResponse.json();
@@ -65,11 +53,9 @@ export function DownloadedScriptsTab({
// Load saved filters if SAVE_FILTER is enabled // Load saved filters if SAVE_FILTER is enabled
if (saveFilterEnabled) { if (saveFilterEnabled) {
const filtersResponse = await fetch("/api/settings/filters"); const filtersResponse = await fetch('/api/settings/filters');
if (filtersResponse.ok) { if (filtersResponse.ok) {
const filtersData = (await filtersResponse.json()) as { const filtersData = await filtersResponse.json();
filters?: Partial<FilterState>;
};
if (filtersData.filters) { if (filtersData.filters) {
setFilters(mergeFiltersWithDefaults(filtersData.filters)); setFilters(mergeFiltersWithDefaults(filtersData.filters));
} }
@@ -77,20 +63,16 @@ export function DownloadedScriptsTab({
} }
// Load view mode // Load view mode
const viewModeResponse = await fetch("/api/settings/view-mode"); const viewModeResponse = await fetch('/api/settings/view-mode');
if (viewModeResponse.ok) { if (viewModeResponse.ok) {
const viewModeData = await viewModeResponse.json(); const viewModeData = await viewModeResponse.json();
const viewMode = viewModeData.viewMode; const viewMode = viewModeData.viewMode;
if ( if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
viewMode &&
typeof viewMode === "string" &&
(viewMode === "card" || viewMode === "list")
) {
setViewMode(viewMode); setViewMode(viewMode);
} }
} }
} catch (error) { } catch (error) {
console.error("Error loading settings:", error); console.error('Error loading settings:', error);
} finally { } finally {
setIsLoadingFilters(false); setIsLoadingFilters(false);
} }
@@ -105,15 +87,15 @@ export function DownloadedScriptsTab({
const saveFilters = async () => { const saveFilters = async () => {
try { try {
await fetch("/api/settings/filters", { await fetch('/api/settings/filters', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ filters }), body: JSON.stringify({ filters }),
}); });
} catch (error) { } catch (error) {
console.error("Error saving filters:", error); console.error('Error saving filters:', error);
} }
}; };
@@ -128,15 +110,15 @@ export function DownloadedScriptsTab({
const saveViewMode = async () => { const saveViewMode = async () => {
try { try {
await fetch("/api/settings/view-mode", { await fetch('/api/settings/view-mode', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ viewMode }), body: JSON.stringify({ viewMode }),
}); });
} catch (error) { } catch (error) {
console.error("Error saving view mode:", error); console.error('Error saving view mode:', error);
} }
}; };
@@ -147,32 +129,31 @@ export function DownloadedScriptsTab({
// Extract categories from metadata // Extract categories from metadata
const categories = React.useMemo((): string[] => { const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
return [];
return (scriptCardsData.metadata.categories as any[]) return (scriptCardsData.metadata.categories as any[])
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list .filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
.sort((a, b) => a.sort_order - b.sort_order) .sort((a, b) => a.sort_order - b.sort_order)
.map((cat) => cat.name as string) .map((cat) => cat.name as string)
.filter((name): name is string => typeof name === "string"); .filter((name): name is string => typeof name === 'string');
}, [scriptCardsData]); }, [scriptCardsData]);
// Get GitHub scripts with download status (deduplicated) // Get GitHub scripts with download status (deduplicated)
const combinedScripts = React.useMemo((): ScriptCardType[] => { const combinedScripts = React.useMemo((): ScriptCardType[] => {
if (!scriptCardsData?.success) return []; if (!scriptCardsData?.success) return [];
// Use Map to deduplicate by slug/name // Use Map to deduplicate by slug/name
const scriptMap = new Map<string, ScriptCardType>(); const scriptMap = new Map<string, ScriptCardType>();
scriptCardsData.cards?.forEach((script: ScriptCardType) => { scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) { if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence // Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) { if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, { scriptMap.set(script.slug, {
...script, ...script,
source: "github" as const, source: 'github' as const,
isDownloaded: false, // Will be updated by status check isDownloaded: false, // Will be updated by status check
isUpToDate: false, // Will be updated by status check isUpToDate: false, // Will be updated by status check
}); });
} }
} }
@@ -184,77 +165,68 @@ export function DownloadedScriptsTab({
// Update scripts with download status and filter to only downloaded scripts // Update scripts with download status and filter to only downloaded scripts
const downloadedScripts = React.useMemo((): ScriptCardType[] => { const downloadedScripts = React.useMemo((): ScriptCardType[] => {
// Helper to normalize identifiers so underscores vs hyphens don't break matches // Helper to normalize identifiers so underscores vs hyphens don't break matches
const normalizeId = (s?: string): string => const normalizeId = (s?: string): string => (s ?? '')
(s ?? "") .toLowerCase()
.toLowerCase() .replace(/\.(sh|bash|py|js|ts)$/g, '')
.replace(/\.(sh|bash|py|js|ts)$/g, "") .replace(/[^a-z0-9]+/g, '-')
.replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, '');
.replace(/^-+|-+$/g, "");
return combinedScripts return combinedScripts
.map((script) => { .map(script => {
if (!script?.name) { if (!script?.name) {
return script; // Return as-is if invalid return script; // Return as-is if invalid
} }
// Check if there's a corresponding local script // Check if there's a corresponding local script
const hasLocalVersion = const hasLocalVersion = localScriptsData?.scripts?.some(local => {
localScriptsData?.scripts?.some((local) => { if (!local?.name) return false;
if (!local?.name) return false;
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives)
// Primary: Exact slug-to-slug matching (most reliable, prevents false positives) if (local.slug && script.slug) {
if (local.slug && script.slug) { if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
if (local.slug.toLowerCase() === script.slug.toLowerCase()) { return true;
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 // Secondary: Check install basenames (for edge cases where install script names differ from slugs)
const normalizedLocal = normalizeId(local.name); // Only use normalized matching for install basenames, not for slug/name matching
const matchesInstallBasename = const normalizedLocal = normalizeId(local.name);
(script as any)?.install_basenames?.some( const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
(base: string) => normalizeId(base) === normalizedLocal, return matchesInstallBasename;
) ?? false; }) ?? false;
return matchesInstallBasename;
}) ?? false;
return { return {
...script, ...script,
isDownloaded: hasLocalVersion, isDownloaded: hasLocalVersion,
}; };
}) })
.filter((script) => script.isDownloaded); // Only show downloaded scripts .filter(script => script.isDownloaded); // Only show downloaded scripts
}, [combinedScripts, localScriptsData]); }, [combinedScripts, localScriptsData]);
// Count scripts per category (using downloaded scripts only) // Count scripts per category (using downloaded scripts only)
const categoryCounts = React.useMemo((): Record<string, number> => { const categoryCounts = React.useMemo((): Record<string, number> => {
if (!scriptCardsData?.success) return {}; if (!scriptCardsData?.success) return {};
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
// Initialize all categories with 0 // Initialize all categories with 0
categories.forEach((categoryName: string) => { categories.forEach((categoryName: string) => {
counts[categoryName] = 0; counts[categoryName] = 0;
}); });
// Count each unique downloaded script only once per category // Count each unique downloaded script only once per category
downloadedScripts.forEach((script) => { downloadedScripts.forEach(script => {
if (script.categoryNames && script.slug) { if (script.categoryNames && script.slug) {
const countedCategories = new Set<string>(); const countedCategories = new Set<string>();
script.categoryNames.forEach((categoryName: unknown) => { script.categoryNames.forEach((categoryName: unknown) => {
if ( if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) {
typeof categoryName === "string" &&
counts[categoryName] !== undefined &&
!countedCategories.has(categoryName)
) {
countedCategories.add(categoryName); countedCategories.add(categoryName);
counts[categoryName]++; counts[categoryName]++;
} }
}); });
} }
}); });
return counts; return counts;
}, [categories, downloadedScripts, scriptCardsData?.success]); }, [categories, downloadedScripts, scriptCardsData?.success]);
@@ -265,15 +237,15 @@ export function DownloadedScriptsTab({
// Filter by search query // Filter by search query
if (filters.searchQuery?.trim()) { if (filters.searchQuery?.trim()) {
const query = filters.searchQuery.toLowerCase().trim(); const query = filters.searchQuery.toLowerCase().trim();
if (query.length >= 1) { if (query.length >= 1) {
scripts = scripts.filter((script) => { scripts = scripts.filter(script => {
if (!script || typeof script !== "object") { if (!script || typeof script !== 'object') {
return false; return false;
} }
const name = (script.name ?? "").toLowerCase(); const name = (script.name ?? '').toLowerCase();
const slug = (script.slug ?? "").toLowerCase(); const slug = (script.slug ?? '').toLowerCase();
return name.includes(query) ?? slug.includes(query); return name.includes(query) ?? slug.includes(query);
}); });
@@ -282,9 +254,9 @@ export function DownloadedScriptsTab({
// Filter by category using real category data from downloaded scripts // Filter by category using real category data from downloaded scripts
if (selectedCategory) { if (selectedCategory) {
scripts = scripts.filter((script) => { scripts = scripts.filter(script => {
if (!script) return false; if (!script) return false;
// Check if the downloaded script has categoryNames that include the selected category // Check if the downloaded script has categoryNames that include the selected category
return script.categoryNames?.includes(selectedCategory) ?? false; return script.categoryNames?.includes(selectedCategory) ?? false;
}); });
@@ -292,7 +264,7 @@ export function DownloadedScriptsTab({
// Filter by updateable status // Filter by updateable status
if (filters.showUpdatable !== null) { if (filters.showUpdatable !== null) {
scripts = scripts.filter((script) => { scripts = scripts.filter(script => {
if (!script) return false; if (!script) return false;
const isUpdatable = script.updateable ?? false; const isUpdatable = script.updateable ?? false;
return filters.showUpdatable ? isUpdatable : !isUpdatable; return filters.showUpdatable ? isUpdatable : !isUpdatable;
@@ -301,30 +273,28 @@ export function DownloadedScriptsTab({
// Filter by script types // Filter by script types
if (filters.selectedTypes.length > 0) { if (filters.selectedTypes.length > 0) {
scripts = scripts.filter((script) => { scripts = scripts.filter(script => {
if (!script) return false; if (!script) return false;
const scriptType = (script.type ?? "").toLowerCase(); const scriptType = (script.type ?? '').toLowerCase();
// Map non-standard types to standard categories // Map non-standard types to standard categories
const mappedType = scriptType === "turnkey" ? "ct" : scriptType; const mappedType = scriptType === 'turnkey' ? 'ct' : scriptType;
return filters.selectedTypes.some( return filters.selectedTypes.some(type => type.toLowerCase() === mappedType);
(type) => type.toLowerCase() === mappedType,
);
}); });
} }
// Filter by repositories // Filter by repositories
if (filters.selectedRepositories.length > 0) { if (filters.selectedRepositories.length > 0) {
scripts = scripts.filter((script) => { scripts = scripts.filter(script => {
if (!script) return false; if (!script) return false;
const repoUrl = script.repository_url; const repoUrl = script.repository_url;
// If script has no repository_url, exclude it when filtering by repositories // If script has no repository_url, exclude it when filtering by repositories
if (!repoUrl) { if (!repoUrl) {
return false; return false;
} }
// Only include scripts from selected repositories // Only include scripts from selected repositories
return filters.selectedRepositories.includes(repoUrl); return filters.selectedRepositories.includes(repoUrl);
}); });
@@ -333,18 +303,18 @@ export function DownloadedScriptsTab({
// Apply sorting // Apply sorting
scripts.sort((a, b) => { scripts.sort((a, b) => {
if (!a || !b) return 0; if (!a || !b) return 0;
let compareValue = 0; let compareValue = 0;
switch (filters.sortBy) { switch (filters.sortBy) {
case "name": case 'name':
compareValue = (a.name ?? "").localeCompare(b.name ?? ""); compareValue = (a.name ?? '').localeCompare(b.name ?? '');
break; break;
case "created": case 'created':
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD") // Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
const aCreated = a?.date_created ?? ""; const aCreated = a?.date_created ?? '';
const bCreated = b?.date_created ?? ""; const bCreated = b?.date_created ?? '';
// If both have dates, compare them directly // If both have dates, compare them directly
if (aCreated && bCreated) { if (aCreated && bCreated) {
// For dates: asc = oldest first (2020 before 2024), desc = newest first (2024 before 2020) // For dates: asc = oldest first (2020 before 2024), desc = newest first (2024 before 2020)
@@ -357,15 +327,15 @@ export function DownloadedScriptsTab({
compareValue = 1; compareValue = 1;
} else { } else {
// Both have no dates, fallback to name comparison // Both have no dates, fallback to name comparison
compareValue = (a.name ?? "").localeCompare(b.name ?? ""); compareValue = (a.name ?? '').localeCompare(b.name ?? '');
} }
break; break;
default: default:
compareValue = (a.name ?? "").localeCompare(b.name ?? ""); compareValue = (a.name ?? '').localeCompare(b.name ?? '');
} }
// Apply sort order // Apply sort order
return filters.sortOrder === "asc" ? compareValue : -compareValue; return filters.sortOrder === 'asc' ? compareValue : -compareValue;
}); });
return scripts; return scripts;
@@ -373,10 +343,8 @@ export function DownloadedScriptsTab({
// Calculate filter counts for FilterBar // Calculate filter counts for FilterBar
const filterCounts = React.useMemo(() => { const filterCounts = React.useMemo(() => {
const updatableCount = downloadedScripts.filter( const updatableCount = downloadedScripts.filter(script => script?.updateable).length;
(script) => script?.updateable,
).length;
return { installedCount: downloadedScripts.length, updatableCount }; return { installedCount: downloadedScripts.length, updatableCount };
}, [downloadedScripts]); }, [downloadedScripts]);
@@ -394,13 +362,13 @@ export function DownloadedScriptsTab({
useEffect(() => { useEffect(() => {
if (selectedCategory && gridRef.current) { if (selectedCategory && gridRef.current) {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
gridRef.current?.scrollIntoView({ gridRef.current?.scrollIntoView({
behavior: "smooth", behavior: 'smooth',
block: "start", block: 'start',
inline: "nearest", inline: 'nearest'
}); });
}, 100); }, 100);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
} }
}, [selectedCategory]); }, [selectedCategory]);
@@ -419,38 +387,22 @@ export function DownloadedScriptsTab({
if (githubLoading || localLoading) { if (githubLoading || localLoading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="text-muted-foreground ml-2"> <span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
Loading downloaded scripts...
</span>
</div> </div>
); );
} }
if (githubError || localError) { if (githubError || localError) {
return ( return (
<div className="py-12 text-center"> <div className="text-center py-12">
<div className="text-error mb-4"> <div className="text-error mb-4">
<svg <svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="mx-auto mb-2 h-12 w-12" <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" />
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> </svg>
<p className="text-lg font-medium"> <p className="text-lg font-medium">Failed to load downloaded scripts</p>
Failed to load downloaded scripts <p className="text-sm text-muted-foreground mt-1">
</p> {githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
<p className="text-muted-foreground mt-1 text-sm">
{githubError?.message ??
localError?.message ??
"Unknown error occurred"}
</p> </p>
</div> </div>
<Button <Button
@@ -467,25 +419,14 @@ export function DownloadedScriptsTab({
if (!downloadedScripts?.length) { if (!downloadedScripts?.length) {
return ( return (
<div className="py-12 text-center"> <div className="text-center py-12">
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<svg <svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="mx-auto mb-4 h-12 w-12" <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" />
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> </svg>
<p className="text-lg font-medium">No downloaded scripts found</p> <p className="text-lg font-medium">No downloaded scripts found</p>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-sm text-muted-foreground mt-1">
You haven&apos;t downloaded any scripts yet. Visit the Available You haven&apos;t downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
Scripts tab to download some scripts.
</p> </p>
</div> </div>
</div> </div>
@@ -494,9 +435,12 @@ export function DownloadedScriptsTab({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
{/* Category Sidebar */} {/* Category Sidebar */}
<div className="order-2 flex-shrink-0 lg:order-1"> <div className="flex-shrink-0 order-2 lg:order-1">
<CategorySidebar <CategorySidebar
categories={categories} categories={categories}
categoryCounts={categoryCounts} categoryCounts={categoryCounts}
@@ -507,7 +451,7 @@ export function DownloadedScriptsTab({
</div> </div>
{/* Main Content */} {/* Main Content */}
<div className="order-1 min-w-0 flex-1 lg:order-2" ref={gridRef}> <div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
{/* Enhanced Filter Bar */} {/* Enhanced Filter Bar */}
<FilterBar <FilterBar
filters={filters} filters={filters}
@@ -520,41 +464,26 @@ export function DownloadedScriptsTab({
/> />
{/* View Toggle */} {/* View Toggle */}
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} /> <ViewToggle
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* Scripts Grid */} {/* Scripts Grid */}
{filteredScripts.length === 0 && {filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
(filters.searchQuery || <div className="text-center py-12">
selectedCategory ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0) ? (
<div className="py-12 text-center">
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<svg <svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="mx-auto mb-4 h-12 w-12" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
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> </svg>
<p className="text-lg font-medium"> <p className="text-lg font-medium">No matching downloaded scripts found</p>
No matching downloaded scripts found <p className="text-sm text-muted-foreground mt-1">
</p>
<p className="text-muted-foreground mt-1 text-sm">
Try different filter settings or clear all filters. Try different filter settings or clear all filters.
</p> </p>
<div className="mt-4 flex justify-center gap-2"> <div className="flex justify-center gap-2 mt-4">
{filters.searchQuery && ( {filters.searchQuery && (
<Button <Button
onClick={() => onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
handleFiltersChange({ ...filters, searchQuery: "" })
}
variant="default" variant="default"
size="default" size="default"
> >
@@ -573,17 +502,18 @@ export function DownloadedScriptsTab({
</div> </div>
</div> </div>
</div> </div>
) : viewMode === "card" ? ( ) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> viewMode === 'card' ? (
{filteredScripts.map((script, index) => { <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties // Add validation to ensure script has required properties
if (!script || typeof script !== "object") { if (!script || typeof script !== 'object') {
return null; return null;
} }
// Create a unique key by combining slug, name, and index to handle duplicates // 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 ( return (
<ScriptCard <ScriptCard
key={uniqueKey} key={uniqueKey}
@@ -592,18 +522,18 @@ export function DownloadedScriptsTab({
/> />
); );
})} })}
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{filteredScripts.map((script, index) => { {filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties // Add validation to ensure script has required properties
if (!script || typeof script !== "object") { if (!script || typeof script !== 'object') {
return null; return null;
} }
// Create a unique key by combining slug, name, and index to handle duplicates // 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 ( return (
<ScriptCardList <ScriptCardList
key={uniqueKey} key={uniqueKey}
@@ -612,7 +542,8 @@ export function DownloadedScriptsTab({
/> />
); );
})} })}
</div> </div>
)
)} )}
<ScriptDetailModal <ScriptDetailModal

View File

@@ -3,17 +3,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon"; import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter, GitBranch } from "lucide-react";
Package,
Monitor,
Wrench,
Server,
FileText,
Calendar,
RefreshCw,
Filter,
GitBranch,
} from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { getDefaultFilters } from "./filterUtils"; import { getDefaultFilters } from "./filterUtils";
@@ -59,11 +49,11 @@ export function FilterBar({
// Fetch enabled repositories // Fetch enabled repositories
const { data: enabledReposData } = api.repositories.getEnabled.useQuery(); const { data: enabledReposData } = api.repositories.getEnabled.useQuery();
const enabledRepos = enabledReposData?.repositories ?? []; const enabledRepos = enabledReposData?.repositories ?? [];
// Helper function to extract repository name from URL // Helper function to extract repository name from URL
const getRepoName = (url: string): string => { const getRepoName = (url: string): string => {
try { try {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url); const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }
@@ -108,33 +98,29 @@ export function FilterBar({
}; };
return ( return (
<div className="border-border bg-card mb-6 rounded-lg border p-4 shadow-sm sm:p-6"> <div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
{/* Loading State */} {/* Loading State */}
{isLoadingFilters && ( {isLoadingFilters && (
<div className="mb-4 flex items-center justify-center py-2"> <div className="mb-4 flex items-center justify-center py-2">
<div className="text-muted-foreground flex items-center space-x-2 text-sm"> <div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div> <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span>Loading saved filters...</span> <span>Loading saved filters...</span>
</div> </div>
</div> </div>
)} )}
{/* Filter Header */} {/* Filter Header */}
{!isLoadingFilters && ( {!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-foreground text-lg font-medium"> <h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
Filter Scripts
</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ContextualHelpIcon <ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
section="available-scripts"
tooltip="Help with filtering and searching"
/>
<Button <Button
onClick={() => setIsMinimized(!isMinimized)} onClick={() => setIsMinimized(!isMinimized)}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground h-8 w-8" className="h-8 w-8 text-muted-foreground hover:text-foreground"
title={isMinimized ? "Expand filters" : "Minimize filters"} title={isMinimized ? "Expand filters" : "Minimize filters"}
> >
<svg <svg
@@ -160,10 +146,10 @@ export function FilterBar({
<> <>
{/* Search Bar */} {/* Search Bar */}
<div className="mb-4"> <div className="mb-4">
<div className="relative w-full max-w-md"> <div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg <svg
className="text-muted-foreground h-5 w-5" className="h-5 w-5 text-muted-foreground"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -181,13 +167,13 @@ export function FilterBar({
placeholder="Search scripts..." placeholder="Search scripts..."
value={filters.searchQuery} value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })} onChange={(e) => updateFilters({ searchQuery: e.target.value })}
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" 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"
/> />
{filters.searchQuery && ( {filters.searchQuery && (
<Button <Button
onClick={() => updateFilters({ searchQuery: "" })} onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost" variant="ghost"
className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 flex h-full items-center justify-center pr-3" className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground"
> >
<svg <svg
className="h-5 w-5" className="h-5 w-5"
@@ -208,335 +194,318 @@ export function FilterBar({
</div> </div>
{/* Filter Buttons */} {/* Filter Buttons */}
<div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3"> <div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */} {/* 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 (
<Button <Button
key={repo.id}
onClick={() => { onClick={() => {
const next = const currentSelected = filters.selectedRepositories;
filters.showUpdatable === null if (isSelected) {
? true // Remove repository from selection
: filters.showUpdatable === true updateFilters({
? false selectedRepositories: currentSelected.filter(url => url !== repo.url)
: null; });
updateFilters({ showUpdatable: next }); } else {
// Add repository to selection
updateFilters({
selectedRepositories: [...currentSelected, repo.url]
});
}
}} }}
variant="outline" variant="outline"
size="default" size="default"
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${ className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
filters.showUpdatable === null isSelected
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground" ? "border border-primary/20 bg-primary/10 text-primary"
: filters.showUpdatable === true : "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
? "border-success/20 bg-success/10 text-success border"
: "border-destructive/20 bg-destructive/10 text-destructive border"
}`} }`}
> >
<RefreshCw className="h-4 w-4" /> <GitBranch className="h-4 w-4" />
<span>{getUpdatableButtonText()}</span> <span>{getRepoName(repo.url)}</span>
</Button> </Button>
);
})}
{/* Type Dropdown */} {/* Sort By Dropdown */}
<div className="relative w-full sm:w-auto"> <div className="relative w-full sm:w-auto">
<Button <Button
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)} onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
variant="outline" variant="outline"
size="default" size="default"
className={`flex w-full items-center justify-center space-x-2 ${ 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.selectedTypes.length === 0 >
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground" {filters.sortBy === "name" ? (
: "border-primary/20 bg-primary/10 text-primary border" <FileText className="h-4 w-4" />
}`} ) : (
> <Calendar className="h-4 w-4" />
<Filter className="h-4 w-4" /> )}
<span>{getTypeButtonText()}</span> <span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
<svg <svg
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`} className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{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>
{/* 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>
);
})}
{/* 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" ? ( <path
<> strokeLinecap="round"
<svg strokeLinejoin="round"
className="h-4 w-4" strokeWidth={2}
fill="none" d="M19 9l-7 7-7-7"
stroke="currentColor" />
viewBox="0 0 24 24" </svg>
> </Button>
<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 */} {isSortDropdownOpen && (
<div className="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center"> <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="flex items-center gap-4"> <div className="p-2">
<div className="text-muted-foreground text-sm"> <button
{filteredCount === totalScripts ? ( onClick={() => {
<span>Showing all {totalScripts} scripts</span> updateFilters({ sortBy: "name" });
) : ( setIsSortDropdownOpen(false);
<span> }}
{filteredCount} of {totalScripts} scripts{" "} className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
{hasActiveFilters && ( filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
<span className="text-info font-medium">(filtered)</span> }`}
)} >
<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>
</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>
{/* 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)
</span> </span>
)} )}
</div> </span>
{/* 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> </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

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, startTransition } from 'react'; import { useState, useEffect } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
@@ -159,13 +159,9 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L
useEffect(() => { useEffect(() => {
if (configData?.success) { if (configData?.success) {
populateFormData(configData); populateFormData(configData);
startTransition(() => { setHasChanges(false);
setHasChanges(false);
});
} else if (configData && !configData.success) { } else if (configData && !configData.success) {
startTransition(() => { setError(String(configData.error ?? 'Failed to load configuration'));
setError(String(configData.error ?? 'Failed to load configuration'));
});
} }
}, [configData]); }, [configData]);

View File

@@ -1,45 +1,34 @@
"use client"; 'use client';
import { Loader2, CheckCircle, X } from "lucide-react"; import { Loader2, CheckCircle, X } from 'lucide-react';
import { useRegisterModal } from "./modal/ModalStackProvider"; import { useRegisterModal } from './modal/ModalStackProvider';
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react';
import { Button } from "./ui/button"; import { Button } from './ui/button';
interface LoadingModalProps { interface LoadingModalProps {
isOpen: boolean; isOpen: boolean;
action?: string; action: string;
logs?: string[]; logs?: string[];
isComplete?: boolean; isComplete?: boolean;
title?: string; title?: string;
onClose?: () => void; onClose?: () => void;
} }
export function LoadingModal({ export function LoadingModal({ isOpen, action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) {
isOpen,
action: _action,
logs = [],
isComplete = false,
title,
onClose,
}: LoadingModalProps) {
// Allow dismissing with ESC only when complete, prevent during running // Allow dismissing with ESC only when complete, prevent during running
useRegisterModal(isOpen, { useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) });
id: "loading-modal",
allowEscape: isComplete,
onClose: onClose ?? (() => null),
});
const logsEndRef = useRef<HTMLDivElement>(null); const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive // Auto-scroll to bottom when new logs arrive
useEffect(() => { useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: "smooth" }); logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]); }, [logs]);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<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"> <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">
{/* Close button - only show when complete */} {/* Close button - only show when complete */}
{isComplete && onClose && ( {isComplete && onClose && (
<Button <Button
@@ -51,30 +40,31 @@ export function LoadingModal({
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
)} )}
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<div className="relative"> <div className="relative">
{isComplete ? ( {isComplete ? (
<CheckCircle className="text-success h-12 w-12" /> <CheckCircle className="h-12 w-12 text-success" />
) : ( ) : (
<> <>
<Loader2 className="text-primary h-12 w-12 animate-spin" /> <Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div> <div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
</> </>
)} )}
</div> </div>
{/* Static title text */} {/* Static title text */}
{title && <p className="text-muted-foreground text-sm">{title}</p>} {title && (
<p className="text-sm text-muted-foreground">
{title}
</p>
)}
{/* Log output */} {/* Log output */}
{logs.length > 0 && ( {logs.length > 0 && (
<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"> <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">
{logs.map((log, index) => ( {logs.map((log, index) => (
<div <div key={index} className="mb-1 whitespace-pre-wrap break-words">
key={index}
className="mb-1 break-words whitespace-pre-wrap"
>
{log} {log}
</div> </div>
))} ))}
@@ -84,15 +74,9 @@ export function LoadingModal({
{!isComplete && ( {!isComplete && (
<div className="flex space-x-1"> <div className="flex space-x-1">
<div className="bg-primary h-2 w-2 animate-bounce rounded-full"></div> <div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div <div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
className="bg-primary h-2 w-2 animate-bounce rounded-full" <div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
style={{ animationDelay: "0.1s" }}
></div>
<div
className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.2s" }}
></div>
</div> </div>
)} )}
</div> </div>
@@ -100,3 +84,4 @@ export function LoadingModal({
</div> </div>
); );
} }

View File

@@ -1,11 +1,11 @@
"use client"; 'use client';
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { Lock, CheckCircle, AlertCircle } from "lucide-react"; import { Lock, CheckCircle, AlertCircle } from 'lucide-react';
import { useRegisterModal } from "./modal/ModalStackProvider"; import { useRegisterModal } from './modal/ModalStackProvider';
import { api } from "~/trpc/react"; import { api } from '~/trpc/react';
import type { Storage } from "~/server/services/storageService"; import type { Storage } from '~/server/services/storageService';
interface PBSCredentialsModalProps { interface PBSCredentialsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -19,84 +19,76 @@ export function PBSCredentialsModal({
isOpen, isOpen,
onClose, onClose,
serverId, serverId,
serverName: _serverName, serverName,
storage, storage
}: PBSCredentialsModalProps) { }: PBSCredentialsModalProps) {
const [pbsIp, setPbsIp] = useState(""); const [pbsIp, setPbsIp] = useState('');
const [pbsDatastore, setPbsDatastore] = useState(""); const [pbsDatastore, setPbsDatastore] = useState('');
const [pbsPassword, setPbsPassword] = useState(""); const [pbsPassword, setPbsPassword] = useState('');
const [pbsFingerprint, setPbsFingerprint] = useState(""); const [pbsFingerprint, setPbsFingerprint] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Extract PBS info from storage object // Extract PBS info from storage object
const pbsIpFromStorage = (storage as { server?: string }).server ?? null; const pbsIpFromStorage = (storage as any).server || null;
const pbsDatastoreFromStorage = const pbsDatastoreFromStorage = (storage as any).datastore || null;
(storage as { datastore?: string }).datastore ?? null;
// Fetch existing credentials // Fetch existing credentials
const { data: credentialData, refetch } = const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery(
api.pbsCredentials.getCredentialsForStorage.useQuery( { serverId, storageName: storage.name },
{ serverId, storageName: storage.name }, { enabled: isOpen }
{ enabled: isOpen }, );
);
// Initialize form with storage config values or existing credentials // Initialize form with storage config values or existing credentials
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (credentialData?.success && credentialData.credential) { if (credentialData?.success && credentialData.credential) {
// Load existing credentials // Load existing credentials
setPbsIp(String(credentialData.credential.pbs_ip)); setPbsIp(credentialData.credential.pbs_ip);
setPbsDatastore(String(credentialData.credential.pbs_datastore)); setPbsDatastore(credentialData.credential.pbs_datastore);
setPbsPassword(""); // Don't show password setPbsPassword(''); // Don't show password
setPbsFingerprint( setPbsFingerprint(credentialData.credential.pbs_fingerprint || '');
String(credentialData.credential.pbs_fingerprint ?? ""),
);
} else { } else {
// Initialize with storage config values // Initialize with storage config values
setPbsIp(pbsIpFromStorage ?? ""); setPbsIp(pbsIpFromStorage || '');
setPbsDatastore(pbsDatastoreFromStorage ?? ""); setPbsDatastore(pbsDatastoreFromStorage || '');
setPbsPassword(""); setPbsPassword('');
setPbsFingerprint(""); setPbsFingerprint('');
} }
} }
}, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]); }, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]);
const saveCredentials = api.pbsCredentials.saveCredentials.useMutation({ const saveCredentials = api.pbsCredentials.saveCredentials.useMutation({
onSuccess: () => { onSuccess: () => {
void refetch(); void refetch();
onClose(); onClose();
}, },
onError: (error) => { 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}`); alert(`Failed to save credentials: ${error.message}`);
}, },
}); });
const deleteCredentials = api.pbsCredentials.deleteCredentials.useMutation({ const deleteCredentials = api.pbsCredentials.deleteCredentials.useMutation({
onSuccess: () => { onSuccess: () => {
void refetch(); void refetch();
onClose(); onClose();
}, },
onError: (error) => { 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}`); alert(`Failed to delete credentials: ${error.message}`);
}, },
}); });
useRegisterModal(isOpen, { useRegisterModal(isOpen, { id: 'pbs-credentials-modal', allowEscape: true, onClose });
id: "pbs-credentials-modal",
allowEscape: true,
onClose,
});
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!pbsIp || !pbsDatastore || !pbsFingerprint) { 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; return;
} }
// Password is optional when updating existing credentials // Password is optional when updating existing credentials
setIsLoading(true); setIsLoading(true);
try { try {
@@ -112,16 +104,12 @@ export function PBSCredentialsModal({
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if ( if (!confirm('Are you sure you want to delete the PBS credentials for this storage?')) {
!confirm(
"Are you sure you want to delete the PBS credentials for this storage?",
)
) {
return; return;
} }
setIsLoading(true); setIsLoading(true);
try { try {
await deleteCredentials.mutateAsync({ await deleteCredentials.mutateAsync({
@@ -132,19 +120,19 @@ export function PBSCredentialsModal({
setIsLoading(false); setIsLoading(false);
} }
}; };
if (!isOpen) return null; if (!isOpen) return null;
const hasCredentials = credentialData?.success && credentialData.credential; const hasCredentials = credentialData?.success && credentialData.credential;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-6"> <div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Lock className="text-primary h-6 w-6" /> <Lock className="h-6 w-6 text-primary" />
<h2 className="text-card-foreground text-2xl font-bold"> <h2 className="text-2xl font-bold text-card-foreground">
PBS Credentials - {storage.name} PBS Credentials - {storage.name}
</h2> </h2>
</div> </div>
@@ -154,31 +142,18 @@ export function PBSCredentialsModal({
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<svg <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-5 w-5" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Storage Name (read-only) */} {/* Storage Name (read-only) */}
<div> <div>
<label <label htmlFor="storage-name" className="block text-sm font-medium text-foreground mb-1">
htmlFor="storage-name"
className="text-foreground mb-1 block text-sm font-medium"
>
Storage Name Storage Name
</label> </label>
<input <input
@@ -186,16 +161,13 @@ export function PBSCredentialsModal({
id="storage-name" id="storage-name"
value={storage.name} value={storage.name}
disabled disabled
className="bg-muted text-muted-foreground border-border w-full cursor-not-allowed rounded-md border px-3 py-2 shadow-sm" className="w-full px-3 py-2 border rounded-md shadow-sm bg-muted text-muted-foreground border-border cursor-not-allowed"
/> />
</div> </div>
{/* PBS IP */} {/* PBS IP */}
<div> <div>
<label <label htmlFor="pbs-ip" className="block text-sm font-medium text-foreground mb-1">
htmlFor="pbs-ip"
className="text-foreground mb-1 block text-sm font-medium"
>
PBS Server IP <span className="text-error">*</span> PBS Server IP <span className="text-error">*</span>
</label> </label>
<input <input
@@ -205,20 +177,17 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsIp(e.target.value)} onChange={(e) => setPbsIp(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
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" 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="e.g., 10.10.10.226" placeholder="e.g., 10.10.10.226"
/> />
<p className="text-muted-foreground mt-1 text-xs"> <p className="mt-1 text-xs text-muted-foreground">
IP address of the Proxmox Backup Server IP address of the Proxmox Backup Server
</p> </p>
</div> </div>
{/* PBS Datastore */} {/* PBS Datastore */}
<div> <div>
<label <label htmlFor="pbs-datastore" className="block text-sm font-medium text-foreground mb-1">
htmlFor="pbs-datastore"
className="text-foreground mb-1 block text-sm font-medium"
>
PBS Datastore <span className="text-error">*</span> PBS Datastore <span className="text-error">*</span>
</label> </label>
<input <input
@@ -228,48 +197,37 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsDatastore(e.target.value)} onChange={(e) => setPbsDatastore(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
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" 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="e.g., NAS03-ISCSI-BACKUP" placeholder="e.g., NAS03-ISCSI-BACKUP"
/> />
<p className="text-muted-foreground mt-1 text-xs"> <p className="mt-1 text-xs text-muted-foreground">
Name of the datastore on the PBS server Name of the datastore on the PBS server
</p> </p>
</div> </div>
{/* PBS Password */} {/* PBS Password */}
<div> <div>
<label <label htmlFor="pbs-password" className="block text-sm font-medium text-foreground mb-1">
htmlFor="pbs-password" Password {!hasCredentials && <span className="text-error">*</span>}
className="text-foreground mb-1 block text-sm font-medium"
>
Password{" "}
{!hasCredentials && <span className="text-error">*</span>}
</label> </label>
<input <input
type="password" type="password"
id="pbs-password" id="pbs-password"
value={pbsPassword} value={pbsPassword}
onChange={(e) => setPbsPassword(e.target.value)} onChange={(e) => setPbsPassword(e.target.value)}
required={!hasCredentials} required={!hasCredentials}
disabled={isLoading} disabled={isLoading}
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" 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={ placeholder={hasCredentials ? "Enter new password (leave empty to keep existing)" : "Enter PBS password"}
hasCredentials
? "Enter new password (leave empty to keep existing)"
: "Enter PBS password"
}
/> />
<p className="text-muted-foreground mt-1 text-xs"> <p className="mt-1 text-xs text-muted-foreground">
Password for root@pam user on PBS server Password for root@pam user on PBS server
</p> </p>
</div> </div>
{/* PBS Fingerprint */} {/* PBS Fingerprint */}
<div> <div>
<label <label htmlFor="pbs-fingerprint" className="block text-sm font-medium text-foreground mb-1">
htmlFor="pbs-fingerprint"
className="text-foreground mb-1 block text-sm font-medium"
>
Fingerprint <span className="text-error">*</span> Fingerprint <span className="text-error">*</span>
</label> </label>
<input <input
@@ -279,37 +237,35 @@ export function PBSCredentialsModal({
onChange={(e) => setPbsFingerprint(e.target.value)} onChange={(e) => setPbsFingerprint(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
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" 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="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" 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="text-muted-foreground mt-1 text-xs"> <p className="mt-1 text-xs text-muted-foreground">
Server fingerprint for auto-acceptance. You can find this on Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button.
your PBS dashboard by clicking the &quot;Show Fingerprint&quot;
button.
</p> </p>
</div> </div>
{/* Status indicator */} {/* Status indicator */}
{hasCredentials && ( {hasCredentials && (
<div className="bg-success/10 border-success/20 flex items-center gap-2 rounded-lg border p-3"> <div className="p-3 bg-success/10 border border-success/20 rounded-lg flex items-center gap-2">
<CheckCircle className="text-success h-4 w-4" /> <CheckCircle className="h-4 w-4 text-success" />
<span className="text-success text-sm font-medium"> <span className="text-sm text-success font-medium">
Credentials are configured for this storage Credentials are configured for this storage
</span> </span>
</div> </div>
)} )}
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col justify-end gap-3 pt-4 sm:flex-row"> <div className="flex flex-col sm:flex-row justify-end gap-3 pt-4">
{hasCredentials && ( {hasCredentials && (
<Button <Button
type="button" type="button"
onClick={handleDelete} onClick={handleDelete}
variant="outline" variant="outline"
disabled={isLoading} disabled={isLoading}
className="order-3 w-full sm:w-auto" className="w-full sm:w-auto order-3"
> >
<AlertCircle className="mr-2 h-4 w-4" /> <AlertCircle className="h-4 w-4 mr-2" />
Delete Credentials Delete Credentials
</Button> </Button>
)} )}
@@ -318,7 +274,7 @@ export function PBSCredentialsModal({
onClick={onClose} onClick={onClose}
variant="outline" variant="outline"
disabled={isLoading} disabled={isLoading}
className="order-2 w-full sm:w-auto" className="w-full sm:w-auto order-2"
> >
Cancel Cancel
</Button> </Button>
@@ -326,13 +282,9 @@ export function PBSCredentialsModal({
type="submit" type="submit"
variant="default" variant="default"
disabled={isLoading} disabled={isLoading}
className="order-1 w-full sm:w-auto" className="w-full sm:w-auto order-1"
> >
{isLoading {isLoading ? 'Saving...' : hasCredentials ? 'Update Credentials' : 'Save Credentials'}
? "Saving..."
: hasCredentials
? "Update Credentials"
: "Save Credentials"}
</Button> </Button>
</div> </div>
</form> </form>
@@ -341,3 +293,4 @@ export function PBSCredentialsModal({
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, startTransition } from 'react'; import { useState, useEffect } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
@@ -47,9 +47,7 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release
// Get current version when modal opens // Get current version when modal opens
useEffect(() => { useEffect(() => {
if (isOpen && versionData?.success && versionData.version) { if (isOpen && versionData?.success && versionData.version) {
startTransition(() => { setCurrentVersion(versionData.version);
setCurrentVersion(versionData.version);
});
} }
}, [isOpen, versionData]); }, [isOpen, versionData]);

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ContextualHelpIcon } from './ContextualHelpIcon'; import { ContextualHelpIcon } from './ContextualHelpIcon';
@@ -11,8 +11,6 @@ export function ResyncButton() {
const [syncMessage, setSyncMessage] = useState<string | null>(null); const [syncMessage, setSyncMessage] = useState<string | null>(null);
const hasReloadedRef = useRef<boolean>(false); const hasReloadedRef = useRef<boolean>(false);
const isUserInitiatedRef = 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({ const resyncMutation = api.scripts.resyncScripts.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
@@ -23,16 +21,7 @@ export function ResyncButton() {
// Only reload if this was triggered by user action // Only reload if this was triggered by user action
if (isUserInitiatedRef.current && !hasReloadedRef.current) { if (isUserInitiatedRef.current && !hasReloadedRef.current) {
hasReloadedRef.current = true; hasReloadedRef.current = true;
setTimeout(() => {
// 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(); window.location.reload();
}, 2000); // Wait 2 seconds to show the success message }, 2000); // Wait 2 seconds to show the success message
} else { } else {
@@ -42,26 +31,14 @@ export function ResyncButton() {
} else { } else {
setSyncMessage(data.error ?? 'Failed to sync scripts'); setSyncMessage(data.error ?? 'Failed to sync scripts');
// Clear message after 3 seconds for errors // Clear message after 3 seconds for errors
if (messageTimeoutRef.current) { setTimeout(() => setSyncMessage(null), 3000);
clearTimeout(messageTimeoutRef.current);
}
messageTimeoutRef.current = setTimeout(() => {
setSyncMessage(null);
messageTimeoutRef.current = null;
}, 3000);
isUserInitiatedRef.current = false; isUserInitiatedRef.current = false;
} }
}, },
onError: (error) => { onError: (error) => {
setIsResyncing(false); setIsResyncing(false);
setSyncMessage(`Error: ${error.message}`); setSyncMessage(`Error: ${error.message}`);
if (messageTimeoutRef.current) { setTimeout(() => setSyncMessage(null), 3000);
clearTimeout(messageTimeoutRef.current);
}
messageTimeoutRef.current = setTimeout(() => {
setSyncMessage(null);
messageTimeoutRef.current = null;
}, 3000);
isUserInitiatedRef.current = false; isUserInitiatedRef.current = false;
}, },
}); });
@@ -70,12 +47,6 @@ export function ResyncButton() {
// Prevent multiple simultaneous sync operations // Prevent multiple simultaneous sync operations
if (isResyncing) return; if (isResyncing) return;
// Clear any pending reload timeout
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Mark as user-initiated before starting // Mark as user-initiated before starting
isUserInitiatedRef.current = true; isUserInitiatedRef.current = true;
hasReloadedRef.current = false; hasReloadedRef.current = false;
@@ -84,23 +55,6 @@ export function ResyncButton() {
resyncMutation.mutate(); 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 ( return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium"> <div className="text-sm text-muted-foreground font-medium">

View File

@@ -1,9 +1,9 @@
"use client"; 'use client';
import { useState } from "react"; import { useState } from 'react';
import Image from "next/image"; import Image from 'next/image';
import type { ScriptCard } from "~/types/script"; import type { ScriptCard } from '~/types/script';
import { TypeBadge, UpdateableBadge } from "./Badge"; import { TypeBadge, UpdateableBadge } from './Badge';
interface ScriptCardProps { interface ScriptCardProps {
script: ScriptCard; script: ScriptCard;
@@ -12,12 +12,7 @@ interface ScriptCardProps {
onToggleSelect?: (slug: string) => void; onToggleSelect?: (slug: string) => void;
} }
export function ScriptCard({ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardProps) {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const handleImageError = () => { const handleImageError = () => {
@@ -32,8 +27,8 @@ export function ScriptCard({
}; };
const getRepoName = (url?: string): string => { const getRepoName = (url?: string): string => {
if (!url) return ""; if (!url) return '';
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url); const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }
@@ -42,36 +37,32 @@ export function ScriptCard({
return ( return (
<div <div
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" 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"
onClick={() => onClick(script)} onClick={() => onClick(script)}
> >
{/* Checkbox in top-left corner */} {/* Checkbox in top-left corner */}
{onToggleSelect && ( {onToggleSelect && (
<div className="absolute top-2 left-2 z-10"> <div className="absolute top-2 left-2 z-10">
<div <div
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${ className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
isSelected isSelected
? "bg-primary border-primary text-primary-foreground" ? 'bg-primary border-primary text-primary-foreground'
: "bg-card border-border hover:border-primary/60 hover:bg-accent" : 'bg-card border-border hover:border-primary/60 hover:bg-accent'
}`} }`}
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
> >
{isSelected && ( {isSelected && (
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path <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" />
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> </svg>
)} )}
</div> </div>
</div> </div>
)} )}
<div className="flex flex-1 flex-col p-6"> <div className="p-6 flex-1 flex flex-col">
{/* Header with logo and name */} {/* Header with logo and name */}
<div className="mb-4 flex items-start space-x-4"> <div className="flex items-start space-x-4 mb-4">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{script.logo && !imageError ? ( {script.logo && !imageError ? (
<Image <Image
@@ -79,49 +70,42 @@ export function ScriptCard({
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={48} width={48}
height={48} height={48}
className="h-12 w-12 rounded-lg object-contain" className="w-12 h-12 rounded-lg object-contain"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg"> <div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
<span className="text-muted-foreground text-lg font-semibold"> <span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || "?"} {script.name?.charAt(0)?.toUpperCase() || '?'}
</span> </span>
</div> </div>
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="flex-1 min-w-0">
<h3 className="text-foreground truncate text-lg font-semibold"> <h3 className="text-lg font-semibold text-foreground truncate">
{script.name || "Unnamed Script"} {script.name || 'Unnamed Script'}
</h3> </h3>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{/* Type and Updateable status on first row */} {/* Type and Updateable status on first row */}
<div className="flex flex-wrap items-center gap-1 space-x-2"> <div className="flex items-center space-x-2 flex-wrap gap-1">
<TypeBadge type={script.type ?? "unknown"} /> <TypeBadge type={script.type ?? 'unknown'} />
{script.updateable && <UpdateableBadge />} {script.updateable && <UpdateableBadge />}
{script.repository_url && ( {script.repository_url && (
<span <span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
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)} {getRepoName(script.repository_url)}
</span> </span>
)} )}
</div> </div>
{/* Download Status */} {/* Download Status */}
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div <div className={`w-2 h-2 rounded-full ${
className={`h-2 w-2 rounded-full ${ script.isDownloaded ? 'bg-success' : 'bg-error'
script.isDownloaded ? "bg-success" : "bg-error" }`}></div>
}`} <span className={`text-xs font-medium ${
></div> script.isDownloaded ? 'text-success' : 'text-error'
<span }`}>
className={`text-xs font-medium ${ {script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
</span> </span>
</div> </div>
</div> </div>
@@ -129,8 +113,8 @@ export function ScriptCard({
</div> </div>
{/* Description */} {/* Description */}
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm"> <p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
{script.description || "No description available"} {script.description || 'No description available'}
</p> </p>
{/* Footer with website link */} {/* Footer with website link */}
@@ -140,22 +124,12 @@ export function ScriptCard({
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-info hover:text-info/80 flex items-center space-x-1 text-sm font-medium" className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<span>Website</span> <span>Website</span>
<svg <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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> </svg>
</a> </a>
</div> </div>

View File

@@ -1,9 +1,9 @@
"use client"; 'use client';
import { useState } from "react"; import { useState } from 'react';
import Image from "next/image"; import Image from 'next/image';
import type { ScriptCard } from "~/types/script"; import type { ScriptCard } from '~/types/script';
import { TypeBadge, UpdateableBadge } from "./Badge"; import { TypeBadge, UpdateableBadge } from './Badge';
interface ScriptCardListProps { interface ScriptCardListProps {
script: ScriptCard; script: ScriptCard;
@@ -12,12 +12,7 @@ interface ScriptCardListProps {
onToggleSelect?: (slug: string) => void; onToggleSelect?: (slug: string) => void;
} }
export function ScriptCardList({ export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) {
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardListProps) {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const handleImageError = () => { const handleImageError = () => {
@@ -32,27 +27,26 @@ export function ScriptCardList({
}; };
const formatDate = (dateString?: string) => { const formatDate = (dateString?: string) => {
if (!dateString) return "Unknown"; if (!dateString) return 'Unknown';
try { try {
return new Date(dateString).toLocaleDateString("en-US", { return new Date(dateString).toLocaleDateString('en-US', {
year: "numeric", year: 'numeric',
month: "short", month: 'short',
day: "numeric", day: 'numeric'
}); });
} catch { } catch {
return "Unknown"; return 'Unknown';
} }
}; };
const getCategoryNames = () => { const getCategoryNames = () => {
if (!script.categoryNames || script.categoryNames.length === 0) if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
return "Uncategorized"; return script.categoryNames.join(', ');
return script.categoryNames.join(", ");
}; };
const getRepoName = (url?: string): string => { const getRepoName = (url?: string): string => {
if (!url) return ""; if (!url) return '';
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(url); const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) { if (match) {
return `${match[1]}/${match[2]}`; return `${match[1]}/${match[2]}`;
} }
@@ -61,34 +55,30 @@ export function ScriptCardList({
return ( return (
<div <div
className="bg-card border-border hover:border-primary relative cursor-pointer rounded-lg border shadow-sm transition-shadow duration-200 hover:shadow-md" className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative"
onClick={() => onClick(script)} onClick={() => onClick(script)}
> >
{/* Checkbox */} {/* Checkbox */}
{onToggleSelect && ( {onToggleSelect && (
<div className="absolute top-4 left-4 z-10"> <div className="absolute top-4 left-4 z-10">
<div <div
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${ className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
isSelected isSelected
? "bg-primary border-primary text-primary-foreground" ? 'bg-primary border-primary text-primary-foreground'
: "bg-card border-border hover:border-primary/60 hover:bg-accent" : 'bg-card border-border hover:border-primary/60 hover:bg-accent'
}`} }`}
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
> >
{isSelected && ( {isSelected && (
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path <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" />
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> </svg>
)} )}
</div> </div>
</div> </div>
)} )}
<div className={`p-6 ${onToggleSelect ? "pl-12" : ""}`}> <div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}>
<div className="flex items-start space-x-4"> <div className="flex items-start space-x-4">
{/* Logo */} {/* Logo */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -98,49 +88,42 @@ export function ScriptCardList({
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={56} width={56}
height={56} height={56}
className="h-14 w-14 rounded-lg object-contain" className="w-14 h-14 rounded-lg object-contain"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-lg"> <div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
<span className="text-muted-foreground text-lg font-semibold"> <span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || "?"} {script.name?.charAt(0)?.toUpperCase() || '?'}
</span> </span>
</div> </div>
)} )}
</div> </div>
{/* Main Content */} {/* Main Content */}
<div className="min-w-0 flex-1"> <div className="flex-1 min-w-0">
{/* Header Row */} {/* Header Row */}
<div className="mb-3 flex items-start justify-between"> <div className="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1"> <div className="flex-1 min-w-0">
<h3 className="text-foreground mb-2 truncate text-xl font-semibold"> <h3 className="text-xl font-semibold text-foreground truncate mb-2">
{script.name || "Unnamed Script"} {script.name || 'Unnamed Script'}
</h3> </h3>
<div className="flex flex-wrap items-center gap-2 space-x-3"> <div className="flex items-center space-x-3 flex-wrap gap-2">
<TypeBadge type={script.type ?? "unknown"} /> <TypeBadge type={script.type ?? 'unknown'} />
{script.updateable && <UpdateableBadge />} {script.updateable && <UpdateableBadge />}
{script.repository_url && ( {script.repository_url && (
<span <span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded border border-border" title={script.repository_url}>
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)} {getRepoName(script.repository_url)}
</span> </span>
)} )}
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div <div className={`w-2 h-2 rounded-full ${
className={`h-2 w-2 rounded-full ${ script.isDownloaded ? 'bg-success' : 'bg-error'
script.isDownloaded ? "bg-success" : "bg-error" }`}></div>
}`} <span className={`text-sm font-medium ${
></div> script.isDownloaded ? 'text-success' : 'text-error'
<span }`}>
className={`text-sm font-medium ${ {script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? "Downloaded" : "Not Downloaded"}
</span> </span>
</div> </div>
</div> </div>
@@ -152,128 +135,68 @@ export function ScriptCardList({
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-info hover:text-info/80 ml-4 flex items-center space-x-1 text-sm font-medium" className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1 ml-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<span>Website</span> <span>Website</span>
<svg <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-4 w-4" <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" />
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> </svg>
</a> </a>
)} )}
</div> </div>
{/* Description */} {/* Description */}
<p className="text-muted-foreground mb-4 line-clamp-2 text-sm"> <p className="text-muted-foreground text-sm mb-4 line-clamp-2">
{script.description || "No description available"} {script.description || 'No description available'}
</p> </p>
{/* Metadata Row */} {/* Metadata Row */}
<div className="text-muted-foreground flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<svg <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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> </svg>
<span>Categories: {getCategoryNames()}</span> <span>Categories: {getCategoryNames()}</span>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<svg <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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> </svg>
<span>Created: {formatDate(script.date_created)}</span> <span>Created: {formatDate(script.date_created)}</span>
</div> </div>
{(script.os ?? script.version) && ( {(script.os ?? script.version) && (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<svg <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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> </svg>
<span> <span>
{script.os && script.version {script.os && script.version
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}` ? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
: script.os : script.os
? script.os.charAt(0).toUpperCase() + ? script.os.charAt(0).toUpperCase() + script.os.slice(1)
script.os.slice(1)
: script.version : script.version
? `Version ${script.version}` ? `Version ${script.version}`
: ""} : ''
}
</span> </span>
</div> </div>
)} )}
{script.interface_port && ( {script.interface_port && (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<svg <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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> </svg>
<span>Port: {script.interface_port}</span> <span>Port: {script.interface_port}</span>
</div> </div>
)} )}
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<svg <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-3 w-3" <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" />
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> </svg>
<span>ID: {script.slug || "unknown"}</span> <span>ID: {script.slug || 'unknown'}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -4,20 +4,14 @@ import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import type { Script } from "~/types/script"; import type { Script } from "~/types/script";
import type { Server } from "~/types/server";
import { DiffViewer } from "./DiffViewer"; import { DiffViewer } from "./DiffViewer";
import { TextViewer } from "./TextViewer"; import { TextViewer } from "./TextViewer";
import { ExecutionModeModal } from "./ExecutionModeModal"; import { ExecutionModeModal } from "./ExecutionModeModal";
import { ConfirmationModal } from "./ConfirmationModal"; import { ConfirmationModal } from "./ConfirmationModal";
import { ScriptVersionModal } from "./ScriptVersionModal"; import { ScriptVersionModal } from "./ScriptVersionModal";
import { import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
TypeBadge,
UpdateableBadge,
PrivilegedBadge,
NoteBadge,
} from "./Badge";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { useRegisterModal } from "./modal/ModalStackProvider"; import { useRegisterModal } from './modal/ModalStackProvider';
interface ScriptDetailModalProps { interface ScriptDetailModalProps {
script: Script | null; script: Script | null;
@@ -27,7 +21,7 @@ interface ScriptDetailModalProps {
scriptPath: string, scriptPath: string,
scriptName: string, scriptName: string,
mode?: "local" | "ssh", mode?: "local" | "ssh",
server?: Server, server?: any,
) => void; ) => void;
} }
@@ -37,11 +31,7 @@ export function ScriptDetailModal({
onClose, onClose,
onInstallScript, onInstallScript,
}: ScriptDetailModalProps) { }: ScriptDetailModalProps) {
useRegisterModal(isOpen, { useRegisterModal(isOpen, { id: 'script-detail-modal', allowEscape: true, onClose });
id: "script-detail-modal",
allowEscape: true,
onClose,
});
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loadMessage, setLoadMessage] = useState<string | null>(null); const [loadMessage, setLoadMessage] = useState<string | null>(null);
@@ -50,9 +40,7 @@ export function ScriptDetailModal({
const [textViewerOpen, setTextViewerOpen] = useState(false); const [textViewerOpen, setTextViewerOpen] = useState(false);
const [executionModeOpen, setExecutionModeOpen] = useState(false); const [executionModeOpen, setExecutionModeOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false); const [versionModalOpen, setVersionModalOpen] = useState(false);
const [selectedVersionType, setSelectedVersionType] = useState<string | null>( const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
null,
);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -73,7 +61,7 @@ export function ScriptDetailModal({
isLoading: comparisonLoading, isLoading: comparisonLoading,
} = api.scripts.compareScriptContent.useQuery( } = api.scripts.compareScriptContent.useQuery(
{ slug: script?.slug ?? "" }, { slug: script?.slug ?? "" },
{ {
enabled: !!script && isOpen, enabled: !!script && isOpen,
refetchOnMount: true, refetchOnMount: true,
staleTime: 0, staleTime: 0,
@@ -152,27 +140,22 @@ export function ScriptDetailModal({
const handleInstallScript = () => { const handleInstallScript = () => {
if (!script) return; if (!script) return;
// Check if script has multiple variants (default and alpine) // Check if script has multiple variants (default and alpine)
const installMethods = script.install_methods || []; const installMethods = script.install_methods || [];
const hasMultipleVariants = const hasMultipleVariants = installMethods.filter(method =>
installMethods.filter( method.type === 'default' || method.type === 'alpine'
(method) => method.type === "default" || method.type === "alpine", ).length > 1;
).length > 1;
if (hasMultipleVariants) { if (hasMultipleVariants) {
// Show version selection modal first // Show version selection modal first
setVersionModalOpen(true); setVersionModalOpen(true);
} else { } else {
// Only one variant, proceed directly to execution mode // Only one variant, proceed directly to execution mode
// Use the first available method or default to 'default' type // Use the first available method or default to 'default' type
const defaultMethod = installMethods.find( const defaultMethod = installMethods.find(method => method.type === 'default');
(method) => method.type === "default",
);
const firstMethod = installMethods[0]; const firstMethod = installMethods[0];
setSelectedVersionType( setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
defaultMethod?.type ?? firstMethod?.type ?? "default",
);
setExecutionModeOpen(true); setExecutionModeOpen(true);
} }
}; };
@@ -183,16 +166,17 @@ export function ScriptDetailModal({
setExecutionModeOpen(true); setExecutionModeOpen(true);
}; };
const handleExecuteScript = (mode: "local" | "ssh", server?: Server) => { const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
if (!script || !onInstallScript) return; if (!script || !onInstallScript) return;
// Find the script path based on selected version type // Find the script path based on selected version type
const versionType = selectedVersionType ?? "default"; const versionType = selectedVersionType || 'default';
const scriptMethod = const scriptMethod = script.install_methods?.find(
script.install_methods?.find( (method) => method.type === versionType && method.script,
(method) => method.type === versionType && method.script, ) || script.install_methods?.find(
) ?? script.install_methods?.find((method) => method.script); (method) => method.script,
);
if (scriptMethod?.script) { if (scriptMethod?.script) {
const scriptPath = `scripts/${scriptMethod.script}`; const scriptPath = `scripts/${scriptMethod.script}`;
const scriptName = script.name; const scriptName = script.name;
@@ -223,31 +207,31 @@ export function ScriptDetailModal({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<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"> <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">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-4 sm:p-6"> <div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
<div className="flex min-w-0 flex-1 items-center space-x-3 sm:space-x-4"> <div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
{script.logo && !imageError ? ( {script.logo && !imageError ? (
<Image <Image
src={script.logo} src={script.logo}
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={64} width={64}
height={64} height={64}
className="h-12 w-12 flex-shrink-0 rounded-lg object-contain sm:h-16 sm:w-16" className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="bg-muted flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg sm:h-16 sm:w-16"> <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-muted-foreground text-lg font-semibold sm:text-2xl"> <span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
{script.name.charAt(0).toUpperCase()} {script.name.charAt(0).toUpperCase()}
</span> </span>
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="text-foreground truncate text-xl font-bold sm:text-2xl"> <h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
{script.name} {script.name}
</h2> </h2>
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2"> <div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
@@ -259,39 +243,37 @@ export function ScriptDetailModal({
href={script.repository_url} href={script.repository_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
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" 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"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
title={`Source: ${script.repository_url}`} title={`Source: ${script.repository_url}`}
> >
{/github\.com\/([^\/]+)\/([^\/]+)/ {script.repository_url.match(/github\.com\/([^\/]+)\/([^\/]+)/)?.[0]?.replace('https://', '') ?? script.repository_url}
.exec(script.repository_url)?.[0]
?.replace("https://", "") ?? script.repository_url}
</a> </a>
)} )}
</div> </div>
</div> </div>
{/* Interface Port*/} {/* Interface Port*/}
{script.interface_port && ( {script.interface_port && (
<div className="ml-3 flex-shrink-0 sm:ml-4"> <div className="ml-3 sm:ml-4 flex-shrink-0">
<div className="bg-primary/10 border-primary/30 rounded-lg border px-3 py-1.5 sm:px-4 sm:py-2"> <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-muted-foreground mr-2 text-xs font-medium sm:text-sm"> <span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2">
Port: Port:
</span> </span>
<span className="text-foreground font-mono text-sm font-semibold sm:text-base"> <span className="text-sm sm:text-base font-semibold text-foreground font-mono">
{script.interface_port} {script.interface_port}
</span> </span>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Close Button */} {/* Close Button */}
<Button <Button
onClick={onClose} onClick={onClose}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground ml-4 flex-shrink-0" className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
> >
<svg <svg
className="h-5 w-5 sm:h-6 sm:w-6" className="h-5 w-5 sm:h-6 sm:w-6"
@@ -310,118 +292,16 @@ export function ScriptDetailModal({
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<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"> <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 */} {/* Install Button - only show if script files exist */}
{scriptFilesData?.success && {scriptFilesData?.success &&
scriptFilesData.ctExists && scriptFilesData.ctExists &&
onInstallScript && ( onInstallScript && (
<Button <Button
onClick={handleInstallScript} onClick={handleInstallScript}
variant="outline" variant="outline"
size="default" size="default"
className="flex w-full items-center justify-center space-x-2 sm:w-auto" className="w-full sm:w-auto flex items-center justify-center space-x-2"
>
<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>
)}
{/* 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"
>
<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;
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"
}`}
>
{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="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 <svg
className="h-4 w-4" className="h-4 w-4"
@@ -433,28 +313,168 @@ export function ScriptDetailModal({
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} 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" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/> />
</svg> </svg>
<span>Up to Date</span> <span>Install</span>
</button> </Button>
); )}
} else {
// Local files exist but have differences - show Update button {/* View Button - only show if script files exist */}
return ( {scriptFilesData?.success &&
<button (scriptFilesData.ctExists || scriptFilesData.installExists) && (
onClick={handleLoadScript} <Button
disabled={isLoading} onClick={handleViewScript}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${ variant="outline"
isLoading size="default"
? "bg-muted text-muted-foreground cursor-not-allowed" className="w-full sm:w-auto flex items-center justify-center space-x-2"
: "bg-warning text-warning-foreground hover:bg-warning/90"
}`}
> >
{isLoading ? ( <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>
)}
{/* 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"
>
{isDeleting ? (
<> <>
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Updating...</span> <span>Deleting...</span>
</> </>
) : ( ) : (
<> <>
@@ -468,61 +488,23 @@ export function ScriptDetailModal({
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} 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" 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> </svg>
<span>Update Script</span> <span>Delete Script</span>
</> </>
)} )}
</button> </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> </div>
{/* Content */} {/* Content */}
<div className="space-y-4 p-4 sm:space-y-6 sm:p-6"> <div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* Script Files Status */} {/* Script Files Status */}
{(scriptFilesLoading || comparisonLoading) && ( {(scriptFilesLoading || comparisonLoading) && (
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm"> <div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-b-2"></div> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
<span>Loading script status...</span> <span>Loading script status...</span>
</div> </div>
</div> </div>
@@ -545,8 +527,8 @@ export function ScriptDetailModal({
} }
return ( return (
<div className="bg-muted text-muted-foreground mb-4 rounded-lg p-3 text-sm"> <div className="mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<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 flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`} className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted"}`}
@@ -585,33 +567,31 @@ export function ScriptDetailModal({
</> </>
) : comparisonLoading ? ( ) : comparisonLoading ? (
<> <>
<div className="bg-muted h-2 w-2 animate-pulse rounded-full"></div> <div className="h-2 w-2 rounded-full bg-muted animate-pulse"></div>
<span>Checking for updates...</span> <span>Checking for updates...</span>
</> </>
) : comparisonData?.error ? ( ) : comparisonData?.error ? (
<> <>
<div className="bg-destructive h-2 w-2 rounded-full"></div> <div className="h-2 w-2 rounded-full bg-destructive"></div>
<span className="text-destructive"> <span className="text-destructive">Error: {comparisonData.error}</span>
Error: {comparisonData.error}
</span>
</> </>
) : ( ) : (
<> <>
<div className="bg-muted h-2 w-2 rounded-full"></div> <div className="h-2 w-2 rounded-full bg-muted"></div>
<span>Status: Unknown</span> <span>Status: Unknown</span>
</> </>
)} )}
<button <button
onClick={() => void refetchComparison()} onClick={() => void refetchComparison()}
disabled={comparisonLoading} 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" className="ml-2 p-1.5 rounded-md hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
title="Refresh comparison" title="Refresh comparison"
> >
{comparisonLoading ? ( {comparisonLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div> <div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
) : ( ) : (
<svg <svg
className="text-muted-foreground hover:text-foreground h-4 w-4" className="h-4 w-4 text-muted-foreground hover:text-foreground"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -629,7 +609,7 @@ export function ScriptDetailModal({
)} )}
</div> </div>
{scriptFilesData.files.length > 0 && ( {scriptFilesData.files.length > 0 && (
<div className="text-muted-foreground mt-2 text-xs break-words"> <div className="mt-2 text-xs text-muted-foreground break-words">
Files: {scriptFilesData.files.join(", ")} Files: {scriptFilesData.files.join(", ")}
</div> </div>
)} )}
@@ -639,17 +619,17 @@ export function ScriptDetailModal({
{/* Load Message */} {/* Load Message */}
{loadMessage && ( {loadMessage && (
<div className="bg-primary/10 text-primary mb-4 rounded-lg p-3 text-sm"> <div className="mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
{loadMessage} {loadMessage}
</div> </div>
)} )}
{/* Description */} {/* Description */}
<div> <div>
<h3 className="text-foreground mb-2 text-base font-semibold sm:text-lg"> <h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
Description Description
</h3> </h3>
<p className="text-muted-foreground text-sm sm:text-base"> <p className="text-sm sm:text-base text-muted-foreground">
{script.description} {script.description}
</p> </p>
</div> </div>
@@ -657,50 +637,50 @@ export function ScriptDetailModal({
{/* Basic Information */} {/* Basic Information */}
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
<div> <div>
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Basic Information Basic Information
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Slug Slug
</dt> </dt>
<dd className="text-foreground font-mono text-sm"> <dd className="font-mono text-sm text-foreground">
{script.slug} {script.slug}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Date Created Date Created
</dt> </dt>
<dd className="text-foreground text-sm"> <dd className="text-sm text-foreground">
{script.date_created} {script.date_created}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Categories Categories
</dt> </dt>
<dd className="text-foreground text-sm"> <dd className="text-sm text-foreground">
{script.categories.join(", ")} {script.categories.join(", ")}
</dd> </dd>
</div> </div>
{script.interface_port && ( {script.interface_port && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Interface Port Interface Port
</dt> </dt>
<dd className="text-foreground text-sm"> <dd className="text-sm text-foreground">
{script.interface_port} {script.interface_port}
</dd> </dd>
</div> </div>
)} )}
{script.config_path && ( {script.config_path && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Config Path Config Path
</dt> </dt>
<dd className="text-foreground font-mono text-sm"> <dd className="font-mono text-sm text-foreground">
{script.config_path} {script.config_path}
</dd> </dd>
</div> </div>
@@ -709,13 +689,13 @@ export function ScriptDetailModal({
</div> </div>
<div> <div>
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Links Links
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
{script.website && ( {script.website && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Website Website
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">
@@ -723,7 +703,7 @@ export function ScriptDetailModal({
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:text-primary/80 break-all" className="break-all text-primary hover:text-primary/80"
> >
{script.website} {script.website}
</a> </a>
@@ -732,7 +712,7 @@ export function ScriptDetailModal({
)} )}
{script.documentation && ( {script.documentation && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Documentation Documentation
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">
@@ -740,7 +720,7 @@ export function ScriptDetailModal({
href={script.documentation} href={script.documentation}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:text-primary/80 break-all" className="break-all text-primary hover:text-primary/80"
> >
{script.documentation} {script.documentation}
</a> </a>
@@ -756,26 +736,26 @@ export function ScriptDetailModal({
script.type !== "pve" && script.type !== "pve" &&
script.type !== "addon" && ( script.type !== "addon" && (
<div> <div>
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Install Methods Install Methods
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{script.install_methods.map((method, index) => ( {script.install_methods.map((method, index) => (
<div <div
key={index} key={index}
className="border-border bg-card rounded-lg border p-3 sm:p-4" className="rounded-lg border border-border bg-card p-3 sm:p-4"
> >
<div className="mb-3 flex flex-col justify-between space-y-1 sm:flex-row sm:items-center sm:space-y-0"> <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-foreground text-sm font-medium capitalize sm:text-base"> <h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
{method.type} {method.type}
</h4> </h4>
<span className="text-muted-foreground font-mono text-xs break-all sm:text-sm"> <span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
{method.script} {method.script}
</span> </span>
</div> </div>
<div className="grid grid-cols-2 gap-2 text-xs sm:gap-4 sm:text-sm lg:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
<div> <div>
<dt className="text-muted-foreground font-medium"> <dt className="font-medium text-muted-foreground">
CPU CPU
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -783,7 +763,7 @@ export function ScriptDetailModal({
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground font-medium"> <dt className="font-medium text-muted-foreground">
RAM RAM
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -791,7 +771,7 @@ export function ScriptDetailModal({
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground font-medium"> <dt className="font-medium text-muted-foreground">
HDD HDD
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -799,7 +779,7 @@ export function ScriptDetailModal({
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground font-medium"> <dt className="font-medium text-muted-foreground">
OS OS
</dt> </dt>
<dd className="text-foreground"> <dd className="text-foreground">
@@ -817,26 +797,26 @@ export function ScriptDetailModal({
{(script.default_credentials.username ?? {(script.default_credentials.username ??
script.default_credentials.password) && ( script.default_credentials.password) && (
<div> <div>
<h3 className="text-foreground mb-3 text-base font-semibold sm:text-lg"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Default Credentials Default Credentials
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
{script.default_credentials.username && ( {script.default_credentials.username && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Username Username
</dt> </dt>
<dd className="text-foreground font-mono text-sm"> <dd className="font-mono text-sm text-foreground">
{script.default_credentials.username} {script.default_credentials.username}
</dd> </dd>
</div> </div>
)} )}
{script.default_credentials.password && ( {script.default_credentials.password && (
<div> <div>
<dt className="text-muted-foreground text-sm font-medium"> <dt className="text-sm font-medium text-muted-foreground">
Password Password
</dt> </dt>
<dd className="text-foreground font-mono text-sm"> <dd className="font-mono text-sm text-foreground">
{script.default_credentials.password} {script.default_credentials.password}
</dd> </dd>
</div> </div>
@@ -848,7 +828,7 @@ export function ScriptDetailModal({
{/* Notes */} {/* Notes */}
{script.notes.length > 0 && ( {script.notes.length > 0 && (
<div> <div>
<h3 className="text-foreground mb-3 text-lg font-semibold"> <h3 className="mb-3 text-lg font-semibold text-foreground">
Notes Notes
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2">
@@ -863,17 +843,14 @@ export function ScriptDetailModal({
key={index} key={index}
className={`rounded-lg p-3 text-sm ${ className={`rounded-lg p-3 text-sm ${
noteType === "warning" noteType === "warning"
? "border-warning bg-warning/10 text-warning border-l-4" ? "border-l-4 border-warning bg-warning/10 text-warning"
: noteType === "error" : noteType === "error"
? "border-destructive bg-destructive/10 text-destructive border-l-4" ? "border-l-4 border-destructive bg-destructive/10 text-destructive"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
}`} }`}
> >
<div className="flex items-start"> <div className="flex items-start">
<NoteBadge <NoteBadge noteType={noteType as 'info' | 'warning' | 'error'} className="mr-2 flex-shrink-0">
noteType={noteType as "info" | "warning" | "error"}
className="mr-2 flex-shrink-0"
>
{noteType} {noteType}
</NoteBadge> </NoteBadge>
<span>{noteText}</span> <span>{noteText}</span>
@@ -905,13 +882,7 @@ export function ScriptDetailModal({
<TextViewer <TextViewer
scriptName={ scriptName={
script.install_methods script.install_methods
?.find( ?.find((method) => method.script && (method.script.startsWith("ct/") || method.script.startsWith("vm/") || method.script.startsWith("tools/")))
(method) =>
method.script &&
(method.script.startsWith("ct/") ||
method.script.startsWith("vm/") ||
method.script.startsWith("tools/")),
)
?.script?.split("/") ?.script?.split("/")
.pop() ?? `${script.slug}.sh` .pop() ?? `${script.slug}.sh`
} }

View File

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

View File

@@ -1,9 +1,9 @@
"use client"; 'use client';
import { useState } from "react"; import { useState } from 'react';
import type { Script } from "../../types/script"; import type { Script, ScriptInstallMethod } from '../../types/script';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { useRegisterModal } from "./modal/ModalStackProvider"; import { useRegisterModal } from './modal/ModalStackProvider';
interface ScriptVersionModalProps { interface ScriptVersionModalProps {
isOpen: boolean; isOpen: boolean;
@@ -12,29 +12,16 @@ interface ScriptVersionModalProps {
script: Script | null; script: Script | null;
} }
export function ScriptVersionModal({ export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
isOpen, useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
onClose,
onSelectVersion,
script,
}: ScriptVersionModalProps) {
useRegisterModal(isOpen, {
id: "script-version-modal",
allowEscape: true,
onClose,
});
const [selectedVersion, setSelectedVersion] = useState<string | null>(null); const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
if (!isOpen || !script) return null; if (!isOpen || !script) return null;
// Get available install methods // Get available install methods
const installMethods = script.install_methods || []; const installMethods = script.install_methods || [];
const defaultMethod = installMethods.find( const defaultMethod = installMethods.find(method => method.type === 'default');
(method) => method.type === "default", const alpineMethod = installMethods.find(method => method.type === 'alpine');
);
const alpineMethod = installMethods.find(
(method) => method.type === "alpine",
);
const handleConfirm = () => { const handleConfirm = () => {
if (selectedVersion) { if (selectedVersion) {
@@ -48,29 +35,19 @@ export function ScriptVersionModal({
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border w-full max-w-2xl rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-6"> <div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-foreground text-xl font-bold">Select Version</h2> <h2 className="text-xl font-bold text-foreground">Select Version</h2>
<Button <Button
onClick={onClose} onClick={onClose}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<svg <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-6 w-6" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
@@ -78,12 +55,11 @@ export function ScriptVersionModal({
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
<h3 className="text-foreground mb-2 text-lg font-medium"> <h3 className="text-lg font-medium text-foreground mb-2">
Choose a version for &quot;{script.name}&quot; Choose a version for &quot;{script.name}&quot;
</h3> </h3>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
Select the version you want to install. Each version has different Select the version you want to install. Each version has different resource requirements.
resource requirements.
</p> </p>
</div> </div>
@@ -91,29 +67,25 @@ export function ScriptVersionModal({
{/* Default Version */} {/* Default Version */}
{defaultMethod && ( {defaultMethod && (
<div <div
onClick={() => handleVersionSelect("default")} onClick={() => handleVersionSelect('default')}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${ className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === "default" selectedVersion === 'default'
? "border-primary bg-primary/10" ? 'border-primary bg-primary/10'
: "border-border bg-card hover:border-primary/50" : 'border-border bg-card hover:border-primary/50'
}`} }`}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="mb-3 flex items-center space-x-3"> <div className="flex items-center space-x-3 mb-3">
<div <div
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${ className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === "default" selectedVersion === 'default'
? "border-primary bg-primary" ? 'border-primary bg-primary'
: "border-border" : 'border-border'
}`} }`}
> >
{selectedVersion === "default" && ( {selectedVersion === 'default' && (
<svg <svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
className="h-3 w-3 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path <path
fillRule="evenodd" 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" 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"
@@ -122,34 +94,27 @@ export function ScriptVersionModal({
</svg> </svg>
)} )}
</div> </div>
<h4 className="text-foreground text-base font-semibold capitalize"> <h4 className="text-base font-semibold text-foreground capitalize">
{defaultMethod.type} {defaultMethod.type}
</h4> </h4>
</div> </div>
<div className="ml-8 grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div> <div>
<span className="text-muted-foreground">CPU: </span> <span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
{defaultMethod.resources.cpu} cores
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">RAM: </span> <span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
{defaultMethod.resources.ram} MB
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">HDD: </span> <span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
{defaultMethod.resources.hdd} GB
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">OS: </span> <span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
{defaultMethod.resources.os}{" "} {defaultMethod.resources.os} {defaultMethod.resources.version}
{defaultMethod.resources.version}
</span> </span>
</div> </div>
</div> </div>
@@ -161,29 +126,25 @@ export function ScriptVersionModal({
{/* Alpine Version */} {/* Alpine Version */}
{alpineMethod && ( {alpineMethod && (
<div <div
onClick={() => handleVersionSelect("alpine")} onClick={() => handleVersionSelect('alpine')}
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${ className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
selectedVersion === "alpine" selectedVersion === 'alpine'
? "border-primary bg-primary/10" ? 'border-primary bg-primary/10'
: "border-border bg-card hover:border-primary/50" : 'border-border bg-card hover:border-primary/50'
}`} }`}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="mb-3 flex items-center space-x-3"> <div className="flex items-center space-x-3 mb-3">
<div <div
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 ${ className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedVersion === "alpine" selectedVersion === 'alpine'
? "border-primary bg-primary" ? 'border-primary bg-primary'
: "border-border" : 'border-border'
}`} }`}
> >
{selectedVersion === "alpine" && ( {selectedVersion === 'alpine' && (
<svg <svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
className="h-3 w-3 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path <path
fillRule="evenodd" 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" 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"
@@ -192,34 +153,27 @@ export function ScriptVersionModal({
</svg> </svg>
)} )}
</div> </div>
<h4 className="text-foreground text-base font-semibold capitalize"> <h4 className="text-base font-semibold text-foreground capitalize">
{alpineMethod.type} {alpineMethod.type}
</h4> </h4>
</div> </div>
<div className="ml-8 grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm ml-8">
<div> <div>
<span className="text-muted-foreground">CPU: </span> <span className="text-muted-foreground">CPU: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
{alpineMethod.resources.cpu} cores
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">RAM: </span> <span className="text-muted-foreground">RAM: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
{alpineMethod.resources.ram} MB
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">HDD: </span> <span className="text-muted-foreground">HDD: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
{alpineMethod.resources.hdd} GB
</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">OS: </span> <span className="text-muted-foreground">OS: </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
{alpineMethod.resources.os}{" "} {alpineMethod.resources.os} {alpineMethod.resources.version}
{alpineMethod.resources.version}
</span> </span>
</div> </div>
</div> </div>
@@ -230,8 +184,12 @@ export function ScriptVersionModal({
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="mt-6 flex justify-end space-x-3"> <div className="flex justify-end space-x-3 mt-6">
<Button onClick={onClose} variant="outline" size="default"> <Button
onClick={onClose}
variant="outline"
size="default"
>
Cancel Cancel
</Button> </Button>
<Button <Button
@@ -239,9 +197,7 @@ export function ScriptVersionModal({
disabled={!selectedVersion} disabled={!selectedVersion}
variant="default" variant="default"
size="default" size="default"
className={ className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
!selectedVersion ? "bg-muted-foreground cursor-not-allowed" : ""
}
> >
Continue Continue
</Button> </Button>
@@ -251,3 +207,4 @@ export function ScriptVersionModal({
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
"use client"; 'use client';
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import type { CreateServerData } from "../../types/server"; import type { CreateServerData } from '../../types/server';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { SSHKeyInput } from "./SSHKeyInput"; import { SSHKeyInput } from './SSHKeyInput';
import { PublicKeyModal } from "./PublicKeyModal"; import { PublicKeyModal } from './PublicKeyModal';
import { Key } from "lucide-react"; import { Key } from 'lucide-react';
interface ServerFormProps { interface ServerFormProps {
onSubmit: (data: CreateServerData) => void; onSubmit: (data: CreateServerData) => void;
@@ -14,47 +14,40 @@ interface ServerFormProps {
onCancel?: () => void; onCancel?: () => void;
} }
export function ServerForm({ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel }: ServerFormProps) {
onSubmit,
initialData,
isEditing = false,
onCancel,
}: ServerFormProps) {
const [formData, setFormData] = useState<CreateServerData>( const [formData, setFormData] = useState<CreateServerData>(
initialData ?? { initialData ?? {
name: "", name: '',
ip: "", ip: '',
user: "", user: '',
password: "", password: '',
auth_type: "password", auth_type: 'password',
ssh_key: "", ssh_key: '',
ssh_key_passphrase: "", ssh_key_passphrase: '',
ssh_port: 22, ssh_port: 22,
color: "#3b82f6", color: '#3b82f6',
}, }
); );
const [errors, setErrors] = useState< const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
Partial<Record<keyof CreateServerData, string>> const [sshKeyError, setSshKeyError] = useState<string>('');
>({});
const [sshKeyError, setSshKeyError] = useState<string>("");
const [colorCodingEnabled, setColorCodingEnabled] = useState(false); const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isGeneratingKey, setIsGeneratingKey] = useState(false); const [isGeneratingKey, setIsGeneratingKey] = useState(false);
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false); const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
const [generatedPublicKey, setGeneratedPublicKey] = useState(""); const [generatedPublicKey, setGeneratedPublicKey] = useState('');
const [, setIsGeneratedKey] = useState(false); const [, setIsGeneratedKey] = useState(false);
const [, setGeneratedServerId] = useState<number | null>(null); const [, setGeneratedServerId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
const loadColorCodingSetting = async () => { const loadColorCodingSetting = async () => {
try { try {
const response = await fetch("/api/settings/color-coding"); const response = await fetch('/api/settings/color-coding');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setColorCodingEnabled(Boolean(data.enabled)); setColorCodingEnabled(Boolean(data.enabled));
} }
} catch (error) { } catch (error) {
console.error("Error loading color coding setting:", error); console.error('Error loading color coding setting:', error);
} }
}; };
void loadColorCodingSetting(); void loadColorCodingSetting();
@@ -65,16 +58,15 @@ export function ServerForm({
if (!trimmed) return false; if (!trimmed) return false;
// IPv4 validation // IPv4 validation
const ipv4Regex = 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]?)$/;
/^(?:(?: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)) { if (ipv4Regex.test(trimmed)) {
return true; return true;
} }
// Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0) // Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0)
let ipv6Address = trimmed; let ipv6Address = trimmed;
const zoneIdMatch = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed); const zoneIdMatch = trimmed.match(/^(.+)%([a-zA-Z0-9_\-]+)$/);
if (zoneIdMatch?.[1] && zoneIdMatch[2]) { if (zoneIdMatch) {
ipv6Address = zoneIdMatch[1]; ipv6Address = zoneIdMatch[1];
// Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen) // Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen)
const zoneId = zoneIdMatch[2]; const zoneId = zoneIdMatch[2];
@@ -87,11 +79,10 @@ export function ServerForm({
// Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc. // 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 // Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
// Simplified validation: check for valid hex segments separated by colons // Simplified validation: check for valid hex segments separated by colons
const ipv6Pattern = 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]?)$/;
/^(?:[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)) { if (ipv6Pattern.test(ipv6Address)) {
// Additional validation: ensure only one :: compression exists // Additional validation: ensure only one :: compression exists
const compressionCount = (ipv6Address.match(/::/g) ?? []).length; const compressionCount = (ipv6Address.match(/::/g) || []).length;
if (compressionCount <= 1) { if (compressionCount <= 1) {
return true; return true;
} }
@@ -100,19 +91,17 @@ export function ServerForm({
// FQDN/hostname validation (RFC 1123 compliant) // FQDN/hostname validation (RFC 1123 compliant)
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric // Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
// Max length 253 characters, each label max 63 characters // Max length 253 characters, each label max 63 characters
const hostnameRegex = 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])?$/;
/^([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) { if (hostnameRegex.test(trimmed) && trimmed.length <= 253) {
// Additional check: each label (between dots) must be max 63 chars // Additional check: each label (between dots) must be max 63 chars
const labels = trimmed.split("."); const labels = trimmed.split('.');
if (labels.every((label) => label.length > 0 && label.length <= 63)) { if (labels.every(label => label.length > 0 && label.length <= 63)) {
return true; return true;
} }
} }
// Also allow simple hostnames without dots (like 'localhost') // Also allow simple hostnames without dots (like 'localhost')
const simpleHostnameRegex = const simpleHostnameRegex = /^[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 (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) { if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
return true; return true;
} }
@@ -124,44 +113,41 @@ export function ServerForm({
const newErrors: Partial<Record<keyof CreateServerData, string>> = {}; const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
if (!formData.name.trim()) { if (!formData.name.trim()) {
newErrors.name = "Server name is required"; newErrors.name = 'Server name is required';
} }
if (!formData.ip.trim()) { if (!formData.ip.trim()) {
newErrors.ip = "Server address is required"; newErrors.ip = 'Server address is required';
} else { } else {
if (!validateServerAddress(formData.ip)) { if (!validateServerAddress(formData.ip)) {
newErrors.ip = newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
"Please enter a valid IP address (IPv4/IPv6) or hostname";
} }
} }
if (!formData.user.trim()) { if (!formData.user.trim()) {
newErrors.user = "Username is required"; newErrors.user = 'Username is required';
} }
// Validate SSH port // Validate SSH port
if ( if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
formData.ssh_port !== undefined && newErrors.ssh_port = 'SSH port must be between 1 and 65535';
(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 // Validate authentication based on auth_type
const authType = formData.auth_type ?? "password"; const authType = formData.auth_type ?? 'password';
if (authType === "password") { if (authType === 'password') {
if (!formData.password?.trim()) { if (!formData.password?.trim()) {
newErrors.password = "Password is required for password 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';
} }
} }
if (authType === "key") {
if (!formData.ssh_key?.trim()) {
newErrors.ssh_key = "SSH key is required for key authentication";
}
}
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0 && !sshKeyError; return Object.keys(newErrors).length === 0 && !sshKeyError;
@@ -172,411 +158,348 @@ export function ServerForm({
if (validateForm()) { if (validateForm()) {
onSubmit(formData); onSubmit(formData);
if (!isEditing) { if (!isEditing) {
setFormData({ setFormData({
name: "", name: '',
ip: "", ip: '',
user: "", user: '',
password: "", password: '',
auth_type: "password", auth_type: 'password',
ssh_key: "", ssh_key: '',
ssh_key_passphrase: "", ssh_key_passphrase: '',
ssh_port: 22, ssh_port: 22,
color: "#3b82f6", color: '#3b82f6'
}); });
} }
} }
}; };
const handleChange = const handleChange = (field: keyof CreateServerData) => (
(field: keyof CreateServerData) => e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { ) => {
// Special handling for numeric ssh_port: keep it strictly numeric // Special handling for numeric ssh_port: keep it strictly numeric
if (field === "ssh_port") { if (field === 'ssh_port') {
const raw = (e.target as HTMLInputElement).value ?? ""; const raw = (e.target as HTMLInputElement).value ?? '';
const digitsOnly = raw.replace(/\D+/g, ""); const digitsOnly = raw.replace(/\D+/g, '');
setFormData((prev) => ({ setFormData(prev => ({
...prev,
ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
}));
if (errors.ssh_port) {
setErrors((prev) => ({ ...prev, ssh_port: undefined }));
}
return;
}
setFormData((prev) => ({
...prev, ...prev,
[field]: (e.target as HTMLInputElement).value, ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined,
})); }));
// Clear error when user starts typing if (errors.ssh_port) {
if (errors[field]) { setErrors(prev => ({ ...prev, ssh_port: undefined }));
setErrors((prev) => ({ ...prev, [field]: undefined }));
} }
return;
}
// Reset generated key state when switching auth types setFormData(prev => ({ ...prev, [field]: (e.target as HTMLInputElement).value }));
if (field === "auth_type") { // Clear error when user starts typing
setIsGeneratedKey(false); if (errors[field]) {
setGeneratedPublicKey(""); setErrors(prev => ({ ...prev, [field]: undefined }));
} }
};
// Reset generated key state when switching auth types
if (field === 'auth_type') {
setIsGeneratedKey(false);
setGeneratedPublicKey('');
}
};
const handleGenerateKeyPair = async () => { const handleGenerateKeyPair = async () => {
setIsGeneratingKey(true); setIsGeneratingKey(true);
try { try {
const response = await fetch("/api/servers/generate-keypair", { const response = await fetch('/api/servers/generate-keypair', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
}, },
}); });
if (!response.ok) { 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 { const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
success: boolean;
privateKey?: string;
publicKey?: string;
serverId?: number;
error?: string;
};
if (data.success) { if (data.success) {
const serverId = data.serverId ?? 0; const serverId = data.serverId ?? 0;
const keyPath = `data/ssh-keys/server_${serverId}_key`; const keyPath = `data/ssh-keys/server_${serverId}_key`;
setFormData((prev) => ({ setFormData(prev => ({
...prev, ...prev,
ssh_key: data.privateKey ?? "", ssh_key: data.privateKey ?? '',
ssh_key_path: keyPath, ssh_key_path: keyPath,
key_generated: true, key_generated: true
})); }));
setGeneratedPublicKey(data.publicKey ?? ""); setGeneratedPublicKey(data.publicKey ?? '');
setGeneratedServerId(serverId); setGeneratedServerId(serverId);
setIsGeneratedKey(true); setIsGeneratedKey(true);
setShowPublicKeyModal(true); setShowPublicKeyModal(true);
setSshKeyError(""); setSshKeyError('');
} else { } else {
throw new Error(data.error ?? "Failed to generate key pair"); throw new Error(data.error ?? 'Failed to generate key pair');
} }
} catch (error) { } catch (error) {
console.error("Error generating key pair:", error); console.error('Error generating key pair:', error);
setSshKeyError( setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
error instanceof Error ? error.message : "Failed to generate key pair",
);
} finally { } finally {
setIsGeneratingKey(false); setIsGeneratingKey(false);
} }
}; };
const handleSSHKeyChange = (value: string) => { const handleSSHKeyChange = (value: string) => {
setFormData((prev) => ({ ...prev, ssh_key: value })); setFormData(prev => ({ ...prev, ssh_key: value }));
if (errors.ssh_key) { if (errors.ssh_key) {
setErrors((prev) => ({ ...prev, ssh_key: undefined })); setErrors(prev => ({ ...prev, ssh_key: undefined }));
} }
}; };
return ( return (
<> <>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label <label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
htmlFor="name" Server Name *
className="text-muted-foreground mb-1 block text-sm font-medium" </label>
> <input
Server Name * type="text"
</label> id="name"
<input value={formData.name}
type="text" onChange={handleChange('name')}
id="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 ${
value={formData.name} errors.name ? 'border-destructive' : 'border-border'
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 ${ placeholder="e.g., Production Server"
errors.name ? "border-destructive" : "border-border" />
}`} {errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
placeholder="e.g., Production Server"
/>
{errors.name && (
<p className="text-destructive mt-1 text-sm">{errors.name}</p>
)}
</div>
<div>
<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"
>
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"
/>
<span className="text-muted-foreground text-sm">
Choose a color to identify this server
</span>
</div>
</div>
)}
</div> </div>
{/* Password Authentication */} <div>
{formData.auth_type === "password" && ( <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 && (
<div> <div>
<label <label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
htmlFor="password" Server Color
className="text-muted-foreground mb-1 block text-sm font-medium" </label>
> <div className="flex items-center gap-3">
Password * <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>
</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"
>
<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="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>
</div>
)}
</div>
<div>
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
SSH Key Passphrase (Optional)
</label> </label>
<input <input
type="password" type="password"
id="password" id="ssh_key_passphrase"
value={formData.password ?? ""} value={formData.ssh_key_passphrase ?? ''}
onChange={handleChange("password")} onChange={handleChange('ssh_key_passphrase')}
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 ${ 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"
errors.password ? "border-destructive" : "border-border" placeholder="Enter passphrase for encrypted key"
}`}
placeholder="Enter password"
/> />
{errors.password && ( <p className="mt-1 text-xs text-muted-foreground">
<p className="text-destructive mt-1 text-sm">{errors.password}</p> Only required if your SSH key is encrypted with a passphrase
)} </p>
</div> </div>
)}
{/* 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> </div>
</form> )}
{/* Public Key Modal */} <div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
<PublicKeyModal {isEditing && onCancel && (
isOpen={showPublicKeyModal} <Button
onClose={() => setShowPublicKeyModal(false)} type="button"
publicKey={generatedPublicKey} onClick={onCancel}
serverName={formData.name || "New Server"} variant="outline"
serverIp={formData.ip} 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}
/>
</> </>
); );
} }

View File

@@ -1,18 +1,12 @@
"use client"; 'use client';
import { useState } from "react"; import { useState, useEffect } from 'react';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { import { Database, RefreshCw, CheckCircle, Lock, AlertCircle } from 'lucide-react';
Database, import { useRegisterModal } from './modal/ModalStackProvider';
RefreshCw, import { api } from '~/trpc/react';
CheckCircle, import { PBSCredentialsModal } from './PBSCredentialsModal';
Lock, import type { Storage } from '~/server/services/storageService';
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 { interface ServerStoragesModalProps {
isOpen: boolean; isOpen: boolean;
@@ -25,38 +19,30 @@ export function ServerStoragesModal({
isOpen, isOpen,
onClose, onClose,
serverId, serverId,
serverName, serverName
}: ServerStoragesModalProps) { }: ServerStoragesModalProps) {
const [forceRefresh, setForceRefresh] = useState(false); const [forceRefresh, setForceRefresh] = useState(false);
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>( const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(null);
null,
const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery(
{ serverId, forceRefresh },
{ enabled: isOpen }
); );
const { data, isLoading, refetch } =
api.installedScripts.getBackupStorages.useQuery(
{ serverId, forceRefresh },
{ enabled: isOpen },
);
// Fetch all PBS credentials for this server to show status indicators // Fetch all PBS credentials for this server to show status indicators
const { data: allCredentials } = const { data: allCredentials } = api.pbsCredentials.getAllCredentialsForServer.useQuery(
api.pbsCredentials.getAllCredentialsForServer.useQuery( { serverId },
{ serverId }, { enabled: isOpen }
{ enabled: isOpen }, );
);
const credentialsMap = new Map<string, boolean>(); const credentialsMap = new Map<string, boolean>();
if (allCredentials?.success) { if (allCredentials?.success) {
allCredentials.credentials.forEach((c: { storage_name: string }) => { allCredentials.credentials.forEach(c => {
credentialsMap.set(String(c.storage_name), true); credentialsMap.set(c.storage_name, true);
}); });
} }
useRegisterModal(isOpen, { useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose });
id: "server-storages-modal",
allowEscape: true,
onClose,
});
const handleRefresh = () => { const handleRefresh = () => {
setForceRefresh(true); setForceRefresh(true);
@@ -67,16 +53,16 @@ export function ServerStoragesModal({
if (!isOpen) return null; if (!isOpen) return null;
const storages = data?.success ? data.storages : []; const storages = data?.success ? data.storages : [];
const backupStorages = storages.filter((s) => s.supportsBackup); const backupStorages = storages.filter(s => s.supportsBackup);
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-3xl flex-col rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-6"> <div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Database className="text-primary h-6 w-6" /> <Database className="h-6 w-6 text-primary" />
<h2 className="text-card-foreground text-2xl font-bold"> <h2 className="text-2xl font-bold text-card-foreground">
Storages for {serverName} Storages for {serverName}
</h2> </h2>
</div> </div>
@@ -87,9 +73,7 @@ export function ServerStoragesModal({
size="sm" size="sm"
disabled={isLoading} disabled={isLoading}
> >
<RefreshCw <RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh Refresh
</Button> </Button>
<Button <Button
@@ -98,18 +82,8 @@ export function ServerStoragesModal({
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<svg <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-5 w-5" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
@@ -118,112 +92,96 @@ export function ServerStoragesModal({
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
{isLoading ? ( {isLoading ? (
<div className="py-8 text-center"> <div className="text-center py-8">
<div className="border-primary mb-4 inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div> <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
<p className="text-muted-foreground">Loading storages...</p> <p className="text-muted-foreground">Loading storages...</p>
</div> </div>
) : !data?.success ? ( ) : !data?.success ? (
<div className="py-8 text-center"> <div className="text-center py-8">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-foreground mb-2">Failed to load storages</p> <p className="text-foreground mb-2">Failed to load storages</p>
<p className="text-muted-foreground mb-4 text-sm"> <p className="text-sm text-muted-foreground mb-4">
{data?.error ?? "Unknown error occurred"} {data?.error ?? 'Unknown error occurred'}
</p> </p>
<Button onClick={handleRefresh} variant="outline" size="sm"> <Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="h-4 w-4 mr-2" />
Try Again Try Again
</Button> </Button>
</div> </div>
) : storages.length === 0 ? ( ) : storages.length === 0 ? (
<div className="py-8 text-center"> <div className="text-center py-8">
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-foreground mb-2">No storages found</p> <p className="text-foreground mb-2">No storages found</p>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
Make sure your server has storages configured. Make sure your server has storages configured.
</p> </p>
</div> </div>
) : ( ) : (
<> <>
{data.cached && ( {data.cached && (
<div className="bg-muted/50 text-muted-foreground mb-4 rounded-lg p-3 text-sm"> <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 Showing cached data. Click Refresh to fetch latest from server.
server.
</div> </div>
)} )}
<div className="space-y-3"> <div className="space-y-3">
{storages.map((storage) => { {storages.map((storage) => {
const isBackupCapable = storage.supportsBackup; const isBackupCapable = storage.supportsBackup;
return ( return (
<div <div
key={storage.name} key={storage.name}
className={`rounded-lg border p-4 ${ className={`p-4 border rounded-lg ${
isBackupCapable isBackupCapable
? "border-success/50 bg-success/5" ? 'border-success/50 bg-success/5'
: "border-border bg-card" : 'border-border bg-card'
}`} }`}
> >
<div className="flex-1"> <div className="flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<h3 className="text-foreground font-medium"> <h3 className="font-medium text-foreground">{storage.name}</h3>
{storage.name}
</h3>
{isBackupCapable && ( {isBackupCapable && (
<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"> <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">
<CheckCircle className="h-3 w-3" /> <CheckCircle className="h-3 w-3" />
Backup Backup
</span> </span>
)} )}
<span className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs font-medium"> <span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
{storage.type} {storage.type}
</span> </span>
{storage.type === "pbs" && {storage.type === 'pbs' && (
(credentialsMap.has(storage.name) ? ( 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"> <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">
<CheckCircle className="h-3 w-3" /> <CheckCircle className="h-3 w-3" />
Credentials Configured Credentials Configured
</span> </span>
) : ( ) : (
<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"> <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">
<AlertCircle className="h-3 w-3" /> <AlertCircle className="h-3 w-3" />
Credentials Needed Credentials Needed
</span> </span>
))} )
)}
</div> </div>
<div className="text-muted-foreground space-y-1 text-sm"> <div className="text-sm text-muted-foreground space-y-1">
<div> <div>
<span className="font-medium">Content:</span>{" "} <span className="font-medium">Content:</span> {storage.content.join(', ')}
{storage.content.join(", ")}
</div> </div>
{storage.nodes && storage.nodes.length > 0 && ( {storage.nodes && storage.nodes.length > 0 && (
<div> <div>
<span className="font-medium">Nodes:</span>{" "} <span className="font-medium">Nodes:</span> {storage.nodes.join(', ')}
{storage.nodes.join(", ")}
</div> </div>
)} )}
{Object.entries(storage) {Object.entries(storage)
.filter( .filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
([key]) =>
![
"name",
"type",
"content",
"supportsBackup",
"nodes",
].includes(key),
)
.map(([key, value]) => ( .map(([key, value]) => (
<div key={key}> <div key={key}>
<span className="font-medium capitalize"> <span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span> {String(value)}
{key.replace(/_/g, " ")}:
</span>{" "}
{String(value)}
</div> </div>
))} ))}
</div> </div>
{storage.type === "pbs" && ( {storage.type === 'pbs' && (
<div className="border-border mt-3 border-t pt-3"> <div className="mt-3 pt-3 border-t border-border">
<Button <Button
onClick={() => setSelectedPBSStorage(storage)} onClick={() => setSelectedPBSStorage(storage)}
variant="outline" variant="outline"
@@ -231,10 +189,7 @@ export function ServerStoragesModal({
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Lock className="h-4 w-4" /> <Lock className="h-4 w-4" />
{credentialsMap.has(storage.name) {credentialsMap.has(storage.name) ? 'Edit' : 'Configure'} Credentials
? "Edit"
: "Configure"}{" "}
Credentials
</Button> </Button>
</div> </div>
)} )}
@@ -243,13 +198,11 @@ export function ServerStoragesModal({
); );
})} })}
</div> </div>
{backupStorages.length > 0 && ( {backupStorages.length > 0 && (
<div className="bg-success/10 border-success/20 mt-6 rounded-lg border p-4"> <div className="mt-6 p-4 bg-success/10 border border-success/20 rounded-lg">
<p className="text-success text-sm font-medium"> <p className="text-sm text-success font-medium">
{backupStorages.length} storage {backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups
{backupStorages.length !== 1 ? "s" : ""} available for
backups
</p> </p>
</div> </div>
)} )}
@@ -257,7 +210,7 @@ export function ServerStoragesModal({
)} )}
</div> </div>
</div> </div>
{/* PBS Credentials Modal */} {/* PBS Credentials Modal */}
{selectedPBSStorage && ( {selectedPBSStorage && (
<PBSCredentialsModal <PBSCredentialsModal
@@ -271,3 +224,4 @@ export function ServerStoragesModal({
</div> </div>
); );
} }

View File

@@ -380,7 +380,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
wsRef.current.close(); wsRef.current.close();
} }
}; };
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); }, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
const startScript = () => { const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {

View File

@@ -1,10 +1,10 @@
"use client"; 'use client';
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from 'react';
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism"; import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import type { Script } from "../../types/script"; import type { Script } from '../../types/script';
interface TextViewerProps { interface TextViewerProps {
scriptName: string; scriptName: string;
@@ -20,155 +20,113 @@ interface ScriptContent {
alpineInstallScript?: string; alpineInstallScript?: string;
} }
export function TextViewer({ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
scriptName,
isOpen,
onClose,
script,
}: TextViewerProps) {
const [scriptContent, setScriptContent] = useState<ScriptContent>({}); const [scriptContent, setScriptContent] = useState<ScriptContent>({});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"main" | "install">("main"); const [activeTab, setActiveTab] = useState<'main' | 'install'>('main');
const [selectedVersion, setSelectedVersion] = useState<"default" | "alpine">( const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
"default",
);
// Extract slug from script name (remove .sh extension) // Extract slug from script name (remove .sh extension)
const slug = scriptName.replace(/\.sh$/, "").replace(/^alpine-/, ""); const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
// Get default and alpine install methods // Get default and alpine install methods
const defaultMethod = script?.install_methods?.find( const defaultMethod = script?.install_methods?.find(method => method.type === 'default');
(method) => method.type === "default", const alpineMethod = script?.install_methods?.find(method => method.type === 'alpine');
);
const alpineMethod = script?.install_methods?.find(
(method) => method.type === "alpine",
);
// Check if alpine variant exists // Check if alpine variant exists
const hasAlpineVariant = !!alpineMethod; const hasAlpineVariant = !!alpineMethod;
// Get script paths from install_methods // Get script paths from install_methods
const defaultScriptPath = defaultMethod?.script; const defaultScriptPath = defaultMethod?.script;
const alpineScriptPath = alpineMethod?.script; const alpineScriptPath = alpineMethod?.script;
// Determine if install scripts exist (only for ct/ scripts typically) // Determine if install scripts exist (only for ct/ scripts typically)
const hasInstallScript = const hasInstallScript = defaultScriptPath?.startsWith('ct/') || alpineScriptPath?.startsWith('ct/');
defaultScriptPath?.startsWith("ct/") ?? alpineScriptPath?.startsWith("ct/");
// Get script names for display // Get script names for display
const defaultScriptName = scriptName.replace(/^alpine-/, ""); const defaultScriptName = scriptName.replace(/^alpine-/, '');
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
const loadScriptContent = useCallback(async () => { const loadScriptContent = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
// Build fetch requests based on actual script paths from install_methods // Build fetch requests based on actual script paths from install_methods
const requests: Promise<Response>[] = []; const requests: Promise<Response>[] = [];
const requestTypes: Array< const requestTypes: Array<'default-main' | 'default-install' | 'alpine-main' | 'alpine-install'> = [];
"default-main" | "default-install" | "alpine-main" | "alpine-install"
> = [];
// Default main script (ct/, vm/, tools/, etc.) // Default main script (ct/, vm/, tools/, etc.)
if (defaultScriptPath) { if (defaultScriptPath) {
requests.push( requests.push(
fetch( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`)
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`,
),
); );
requestTypes.push("default-main"); requestTypes.push('default-main');
} }
// Default install script (only for ct/ scripts) // Default install script (only for ct/ scripts)
if (hasInstallScript && defaultScriptPath?.startsWith("ct/")) { if (hasInstallScript && defaultScriptPath?.startsWith('ct/')) {
requests.push( requests.push(
fetch( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`,
),
); );
requestTypes.push("default-install"); requestTypes.push('default-install');
} }
// Alpine main script // Alpine main script
if (hasAlpineVariant && alpineScriptPath) { if (hasAlpineVariant && alpineScriptPath) {
requests.push( requests.push(
fetch( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`)
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`,
),
); );
requestTypes.push("alpine-main"); requestTypes.push('alpine-main');
} }
// Alpine install script (only for ct/ scripts) // Alpine install script (only for ct/ scripts)
if ( if (hasAlpineVariant && hasInstallScript && alpineScriptPath?.startsWith('ct/')) {
hasAlpineVariant &&
hasInstallScript &&
alpineScriptPath?.startsWith("ct/")
) {
requests.push( requests.push(
fetch( fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`,
),
); );
requestTypes.push("alpine-install"); requestTypes.push('alpine-install');
} }
const responses = await Promise.allSettled(requests); const responses = await Promise.allSettled(requests);
const content: ScriptContent = {}; const content: ScriptContent = {};
// Process responses based on their types // Process responses based on their types
await Promise.all( await Promise.all(responses.map(async (response, index) => {
responses.map(async (response, index) => { if (response.status === 'fulfilled' && response.value.ok) {
if (response.status === "fulfilled" && response.value.ok) { try {
try { const data = await response.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
const data = (await response.value.json()) as { const type = requestTypes[index];
result?: { if (data.result?.data?.json?.success && data.result.data.json.content) {
data?: { json?: { success?: boolean; content?: string } }; switch (type) {
}; case 'default-main':
}; content.mainScript = data.result.data.json.content;
const type = requestTypes[index]; break;
if ( case 'default-install':
data.result?.data?.json?.success && content.installScript = data.result.data.json.content;
data.result.data.json.content break;
) { case 'alpine-main':
switch (type) { content.alpineMainScript = data.result.data.json.content;
case "default-main": break;
content.mainScript = data.result.data.json.content; case 'alpine-install':
break; content.alpineInstallScript = data.result.data.json.content;
case "default-install": break;
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
} }
} catch {
// Ignore errors
} }
}), }
); }));
setScriptContent(content); setScriptContent(content);
} catch (err) { } catch (err) {
setError( setError(err instanceof Error ? err.message : 'Failed to load script content');
err instanceof Error ? err.message : "Failed to load script content",
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [ }, [defaultScriptPath, alpineScriptPath, slug, hasAlpineVariant, hasInstallScript]);
defaultScriptPath,
alpineScriptPath,
slug,
hasAlpineVariant,
hasInstallScript,
]);
useEffect(() => { useEffect(() => {
if (isOpen && scriptName) { if (isOpen && scriptName) {
@@ -186,58 +144,48 @@ export function TextViewer({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm" className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<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"> <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">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-6"> <div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex flex-1 items-center space-x-4"> <div className="flex items-center space-x-4 flex-1">
<h2 className="text-foreground text-2xl font-bold"> <h2 className="text-2xl font-bold text-foreground">
Script Viewer: {defaultScriptName} Script Viewer: {defaultScriptName}
</h2> </h2>
{hasAlpineVariant && ( {hasAlpineVariant && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
variant={ variant={selectedVersion === 'default' ? 'default' : 'outline'}
selectedVersion === "default" ? "default" : "outline" onClick={() => setSelectedVersion('default')}
}
onClick={() => setSelectedVersion("default")}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
Default Default
</Button> </Button>
<Button <Button
variant={selectedVersion === "alpine" ? "default" : "outline"} variant={selectedVersion === 'alpine' ? 'default' : 'outline'}
onClick={() => setSelectedVersion("alpine")} onClick={() => setSelectedVersion('alpine')}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
Alpine Alpine
</Button> </Button>
</div> </div>
)} )}
{/* Boolean logic intentionally uses || for truthiness checks - eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} {((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) ||
{((selectedVersion === "default" && (selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && (
Boolean(
scriptContent.mainScript ?? scriptContent.installScript,
)) ||
(selectedVersion === "alpine" &&
Boolean(
scriptContent.alpineMainScript ??
scriptContent.alpineInstallScript,
))) && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
variant={activeTab === "main" ? "outline" : "ghost"} variant={activeTab === 'main' ? 'outline' : 'ghost'}
onClick={() => setActiveTab("main")} onClick={() => setActiveTab('main')}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
Script Script
</Button> </Button>
{hasInstallScript && ( {hasInstallScript && (
<Button <Button
variant={activeTab === "install" ? "outline" : "ghost"} variant={activeTab === 'install' ? 'outline' : 'ghost'}
onClick={() => setActiveTab("install")} onClick={() => setActiveTab('install')}
className="px-3 py-1 text-sm" className="px-3 py-1 text-sm"
> >
Install Script Install Script
@@ -250,64 +198,51 @@ export function TextViewer({
onClick={onClose} onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<svg <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-6 w-6" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex-1 overflow-hidden flex flex-col">
{isLoading ? ( {isLoading ? (
<div className="flex h-full items-center justify-center"> <div className="flex items-center justify-center h-full">
<div className="text-muted-foreground text-lg"> <div className="text-lg text-muted-foreground">Loading script content...</div>
Loading script content...
</div>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex h-full items-center justify-center"> <div className="flex items-center justify-center h-full">
<div className="text-destructive text-lg">Error: {error}</div> <div className="text-lg text-destructive">Error: {error}</div>
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{activeTab === "main" && {activeTab === 'main' && (
(selectedVersion === "default" && scriptContent.mainScript ? ( selectedVersion === 'default' && scriptContent.mainScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: "1rem", padding: '1rem',
fontSize: "14px", fontSize: '14px',
lineHeight: "1.5", lineHeight: '1.5',
minHeight: "100%", minHeight: '100%'
}} }}
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
> >
{scriptContent.mainScript} {scriptContent.mainScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : selectedVersion === "alpine" && ) : selectedVersion === 'alpine' && scriptContent.alpineMainScript ? (
scriptContent.alpineMainScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: "1rem", padding: '1rem',
fontSize: "14px", fontSize: '14px',
lineHeight: "1.5", lineHeight: '1.5',
minHeight: "100%", minHeight: '100%'
}} }}
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
@@ -315,43 +250,40 @@ export function TextViewer({
{scriptContent.alpineMainScript} {scriptContent.alpineMainScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex items-center justify-center h-full">
<div className="text-muted-foreground text-lg"> <div className="text-lg text-muted-foreground">
{selectedVersion === "default" {selectedVersion === 'default' ? 'Default script not found' : 'Alpine script not found'}
? "Default script not found"
: "Alpine script not found"}
</div> </div>
</div> </div>
))} )
{activeTab === "install" && )}
(selectedVersion === "default" && {activeTab === 'install' && (
scriptContent.installScript ? ( selectedVersion === 'default' && scriptContent.installScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: "1rem", padding: '1rem',
fontSize: "14px", fontSize: '14px',
lineHeight: "1.5", lineHeight: '1.5',
minHeight: "100%", minHeight: '100%'
}} }}
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
> >
{scriptContent.installScript} {scriptContent.installScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : selectedVersion === "alpine" && ) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
scriptContent.alpineInstallScript ? (
<SyntaxHighlighter <SyntaxHighlighter
language="bash" language="bash"
style={tomorrow} style={tomorrow}
customStyle={{ customStyle={{
margin: 0, margin: 0,
padding: "1rem", padding: '1rem',
fontSize: "14px", fontSize: '14px',
lineHeight: "1.5", lineHeight: '1.5',
minHeight: "100%", minHeight: '100%'
}} }}
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} wrapLines={true}
@@ -359,14 +291,13 @@ export function TextViewer({
{scriptContent.alpineInstallScript} {scriptContent.alpineInstallScript}
</SyntaxHighlighter> </SyntaxHighlighter>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex items-center justify-center h-full">
<div className="text-muted-foreground text-lg"> <div className="text-lg text-muted-foreground">
{selectedVersion === "default" {selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
? "Default install script not found"
: "Alpine install script not found"}
</div> </div>
</div> </div>
))} )
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState, startTransition } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark'; type Theme = 'light' | 'dark';
@@ -31,13 +31,9 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
useEffect(() => { useEffect(() => {
const savedTheme = localStorage.getItem('theme') as Theme; const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) { if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
startTransition(() => { setThemeState(savedTheme);
setThemeState(savedTheme);
});
} }
startTransition(() => { setMounted(true);
setMounted(true);
});
}, []); }, []);
// Apply theme to document element // Apply theme to document element

View File

@@ -1,11 +1,12 @@
"use client"; 'use client';
import { Button } from "./ui/button"; import { api } from '~/trpc/react';
import { Badge } from "./ui/badge"; import { Button } from './ui/button';
import { X, ExternalLink, Calendar, Tag, AlertTriangle } from "lucide-react"; import { Badge } from './ui/badge';
import { useRegisterModal } from "./modal/ModalStackProvider"; import { X, ExternalLink, Calendar, Tag, Loader2, AlertTriangle } from 'lucide-react';
import ReactMarkdown from "react-markdown"; import { useRegisterModal } from './modal/ModalStackProvider';
import remarkGfm from "remark-gfm"; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface UpdateConfirmationModalProps { interface UpdateConfirmationModalProps {
isOpen: boolean; isOpen: boolean;
@@ -22,34 +23,28 @@ interface UpdateConfirmationModalProps {
latestVersion: string; latestVersion: string;
} }
export function UpdateConfirmationModal({ export function UpdateConfirmationModal({
isOpen, isOpen,
onClose, onClose,
onConfirm, onConfirm,
releaseInfo, releaseInfo,
currentVersion, currentVersion,
latestVersion, latestVersion
}: UpdateConfirmationModalProps) { }: UpdateConfirmationModalProps) {
useRegisterModal(isOpen, { useRegisterModal(isOpen, { id: 'update-confirmation-modal', allowEscape: true, onClose });
id: "update-confirmation-modal",
allowEscape: true,
onClose,
});
if (!isOpen || !releaseInfo) return null; if (!isOpen || !releaseInfo) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */} {/* Header */}
<div className="border-border flex items-center justify-between border-b p-6"> <div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AlertTriangle className="text-warning h-6 w-6" /> <AlertTriangle className="h-6 w-6 text-warning" />
<div> <div>
<h2 className="text-card-foreground text-2xl font-bold"> <h2 className="text-2xl font-bold text-card-foreground">Confirm Update</h2>
Confirm Update <p className="text-sm text-muted-foreground mt-1">
</h2>
<p className="text-muted-foreground mt-1 text-sm">
Review the changelog before proceeding with the update Review the changelog before proceeding with the update
</p> </p>
</div> </div>
@@ -65,13 +60,13 @@ export function UpdateConfirmationModal({
</div> </div>
{/* Content */} {/* Content */}
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 space-y-4 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Version Info */} {/* Version Info */}
<div className="bg-muted/50 border-border rounded-lg border p-4"> <div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="mb-3 flex items-center justify-between"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className="text-card-foreground text-lg font-semibold"> <h3 className="text-lg font-semibold text-card-foreground">
{releaseInfo.name || releaseInfo.tagName} {releaseInfo.name || releaseInfo.tagName}
</h3> </h3>
<Badge variant="default" className="text-xs"> <Badge variant="default" className="text-xs">
@@ -94,7 +89,7 @@ export function UpdateConfirmationModal({
</a> </a>
</Button> </Button>
</div> </div>
<div className="text-muted-foreground mb-3 flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Tag className="h-4 w-4" /> <Tag className="h-4 w-4" />
<span>{releaseInfo.tagName}</span> <span>{releaseInfo.tagName}</span>
@@ -102,92 +97,40 @@ export function UpdateConfirmationModal({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span> <span>
{new Date(releaseInfo.publishedAt).toLocaleDateString( {new Date(releaseInfo.publishedAt).toLocaleDateString('en-US', {
"en-US", year: 'numeric',
{ month: 'long',
year: "numeric", day: 'numeric'
month: "long", })}
day: "numeric",
},
)}
</span> </span>
</div> </div>
</div> </div>
<div className="text-muted-foreground text-sm"> <div className="text-sm text-muted-foreground">
<span>Updating from </span> <span>Updating from </span>
<span className="text-card-foreground font-medium"> <span className="font-medium text-card-foreground">v{currentVersion}</span>
v{currentVersion}
</span>
<span> to </span> <span> to </span>
<span className="text-card-foreground font-medium"> <span className="font-medium text-card-foreground">v{latestVersion}</span>
v{latestVersion}
</span>
</div> </div>
</div> </div>
{/* Changelog */} {/* Changelog */}
{releaseInfo.body ? ( {releaseInfo.body ? (
<div className="border-border bg-card rounded-lg border p-6"> <div className="border rounded-lg p-6 border-border bg-card">
<h4 className="text-md text-card-foreground mb-4 font-semibold"> <h4 className="text-md font-semibold text-card-foreground mb-4">Changelog</h4>
Changelog <div className="prose prose-sm max-w-none dark:prose-invert">
</h4> <ReactMarkdown
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
h1: ({ children }) => ( h1: ({children}) => <h1 className="text-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>,
<h1 className="text-card-foreground mt-6 mb-4 text-2xl font-bold"> h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>,
{children} h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>,
</h1> p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>,
), ul: ({children}) => <ul className="list-disc list-inside text-card-foreground mb-3 space-y-1">{children}</ul>,
h2: ({ children }) => ( ol: ({children}) => <ol className="list-decimal list-inside text-card-foreground mb-3 space-y-1">{children}</ol>,
<h2 className="text-card-foreground mt-5 mb-3 text-xl font-semibold"> li: ({children}) => <li className="text-card-foreground">{children}</li>,
{children} a: ({href, children}) => <a href={href} className="text-info hover:text-info/80 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
</h2> strong: ({children}) => <strong className="font-semibold text-card-foreground">{children}</strong>,
), em: ({children}) => <em className="italic text-card-foreground">{children}</em>,
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} {releaseInfo.body}
@@ -195,23 +138,20 @@ export function UpdateConfirmationModal({
</div> </div>
</div> </div>
) : ( ) : (
<div className="border-border bg-card rounded-lg border p-6"> <div className="border rounded-lg p-6 border-border bg-card">
<p className="text-muted-foreground"> <p className="text-muted-foreground">No changelog available for this release.</p>
No changelog available for this release.
</p>
</div> </div>
)} )}
{/* Warning */} {/* Warning */}
<div className="bg-warning/10 border-warning/30 rounded-lg border p-4"> <div className="bg-warning/10 border border-warning/30 rounded-lg p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<AlertTriangle className="text-warning mt-0.5 h-5 w-5 flex-shrink-0" /> <AlertTriangle className="h-5 w-5 text-warning mt-0.5 flex-shrink-0" />
<div className="text-card-foreground text-sm"> <div className="text-sm text-card-foreground">
<p className="mb-1 font-medium">Important:</p> <p className="font-medium mb-1">Important:</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Please review the changelog above for any breaking changes Please review the changelog above for any breaking changes or important updates before proceeding.
or important updates before proceeding. The server will The server will restart automatically after the update completes.
restart automatically after the update completes.
</p> </p>
</div> </div>
</div> </div>
@@ -220,7 +160,7 @@ export function UpdateConfirmationModal({
</div> </div>
{/* Footer */} {/* Footer */}
<div className="border-border bg-muted/30 flex items-center justify-between border-t p-6"> <div className="flex items-center justify-between p-6 border-t border-border bg-muted/30">
<Button onClick={onClose} variant="ghost"> <Button onClick={onClose} variant="ghost">
Cancel Cancel
</Button> </Button>
@@ -232,3 +172,4 @@ export function UpdateConfirmationModal({
</div> </div>
); );
} }

View File

@@ -87,59 +87,37 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
const [shouldSubscribe, setShouldSubscribe] = useState(false); const [shouldSubscribe, setShouldSubscribe] = useState(false);
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null); const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false); const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
const lastLogTimeRef = useRef<number>(0); const lastLogTimeRef = useRef<number>(Date.now());
// 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 reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasReloadedRef = useRef<boolean>(false); const hasReloadedRef = useRef<boolean>(false);
const isUpdatingRef = useRef<boolean>(false); const isUpdatingRef = useRef<boolean>(false);
const isNetworkErrorRef = 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({ const executeUpdate = api.version.executeUpdate.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
setUpdateResult({ success: result.success, message: result.message }); setUpdateResult({ success: result.success, message: result.message });
if (result.success) { if (result.success) {
// Start subscribing to update logs only if we're actually updating // Start subscribing to update logs
if (isUpdatingRef.current) { setShouldSubscribe(true);
setShouldSubscribe(true); setUpdateLogs(['Update started...']);
setUpdateLogs(['Update started...']);
}
} else { } else {
setIsUpdating(false); setIsUpdating(false);
setShouldSubscribe(false); // Reset subscription on failure setShouldSubscribe(false); // Reset subscription on failure
updateSessionIdRef.current = null;
updateStartTimeRef.current = null;
logFileModifiedTimeRef.current = null;
isCompleteProcessedRef.current = false;
} }
}, },
onError: (error) => { onError: (error) => {
setUpdateResult({ success: false, message: error.message }); setUpdateResult({ success: false, message: error.message });
setIsUpdating(false); setIsUpdating(false);
setShouldSubscribe(false); // Reset subscription on error setShouldSubscribe(false); // Reset subscription on error
updateSessionIdRef.current = null;
updateStartTimeRef.current = null;
logFileModifiedTimeRef.current = null;
isCompleteProcessedRef.current = false;
} }
}); });
// Poll for update logs - only enabled when shouldSubscribe is true AND we're updating // Poll for update logs
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, { const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
enabled: shouldSubscribe && isUpdating, enabled: shouldSubscribe,
refetchInterval: shouldSubscribe && isUpdating ? 1000 : false, // Poll every second only when updating refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: false, // Don't poll in background to prevent stale data refetchIntervalInBackground: true,
}); });
// Attempt to reconnect and reload page when server is back // Attempt to reconnect and reload page when server is back
@@ -148,16 +126,8 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
const startReconnectAttempts = useCallback(() => { const startReconnectAttempts = useCallback(() => {
// CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts // CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts
// Only start if we're actually updating and haven't already started // 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 // Double-check isUpdating state to prevent false triggers from stale data
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) { if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.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; return;
} }
@@ -167,7 +137,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
void (async () => { void (async () => {
// Guard: Only proceed if we're still updating and in network error state // Guard: Only proceed if we're still updating and in network error state
// Check refs directly to avoid stale closures // Check refs directly to avoid stale closures
if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current || !updateStartTimeRef.current) { if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current) {
// Clear interval if we're no longer updating // Clear interval if we're no longer updating
if (!isUpdatingRef.current && reconnectIntervalRef.current) { if (!isUpdatingRef.current && reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
@@ -176,29 +146,12 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
return; 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 {
// Try to fetch the root path to check if server is back // Try to fetch the root path to check if server is back
const response = await fetch('/', { method: 'HEAD' }); const response = await fetch('/', { method: 'HEAD' });
if (response.ok || response.status === 200) { if (response.ok || response.status === 200) {
// Double-check we're still updating and session is valid before reloading // Double-check we're still updating before reloading
if (!isUpdatingRef.current || hasReloadedRef.current || !updateStartTimeRef.current) { if (!isUpdatingRef.current || hasReloadedRef.current) {
return;
}
// Final session validation
const finalSessionAge = Date.now() - updateStartTimeRef.current;
if (finalSessionAge > MAX_SESSION_AGE) {
return; return;
} }
@@ -206,21 +159,13 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
hasReloadedRef.current = true; hasReloadedRef.current = true;
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']); setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
// Clear interval // Clear interval and reload
if (reconnectIntervalRef.current) { if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null; reconnectIntervalRef.current = null;
} }
// Clear any existing reload timeout setTimeout(() => {
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Set reload timeout
reloadTimeoutRef.current = setTimeout(() => {
reloadTimeoutRef.current = null;
window.location.reload(); window.location.reload();
}, 1000); }, 1000);
} }
@@ -235,68 +180,21 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
useEffect(() => { useEffect(() => {
// CRITICAL: Only process update logs if we're actually updating // CRITICAL: Only process update logs if we're actually updating
// This prevents stale isComplete data from triggering reloads when not updating // This prevents stale isComplete data from triggering reloads when not updating
if (!isUpdating || !updateStartTimeRef.current) { if (!isUpdating) {
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; return;
} }
if (updateLogsData?.success && updateLogsData.logs) { if (updateLogsData?.success && updateLogsData.logs) {
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(); lastLogTimeRef.current = Date.now();
setTimeout(() => setUpdateLogs(updateLogsData.logs), 0); setUpdateLogs(updateLogsData.logs);
// CRITICAL: Only process isComplete if we're actually updating
if ( // Double-check isUpdating state to prevent false triggers
updateLogsData.isComplete && if (updateLogsData.isComplete && isUpdating) {
isUpdating && setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
updateStartTimeRef.current && setIsNetworkError(true);
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 // Start reconnection attempts when we know update is complete
setTimeout(() => startReconnectAttempts(), 0); startReconnectAttempts();
} }
} }
}, [updateLogsData, startReconnectAttempts, isUpdating]); }, [updateLogsData, startReconnectAttempts, isUpdating]);
@@ -320,10 +218,8 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
// Additional guard: check refs again before triggering and validate session // Additional guard: check refs again before triggering
const sessionAge = updateStartTimeRef.current ? Date.now() - updateStartTimeRef.current : Infinity; if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current) {
const MAX_SESSION_AGE = 30 * 60 * 1000; // 30 minutes
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current && updateStartTimeRef.current && sessionAge < MAX_SESSION_AGE) {
setIsNetworkError(true); setIsNetworkError(true);
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']); setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
@@ -338,26 +234,12 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
// Keep refs in sync with state // Keep refs in sync with state
useEffect(() => { useEffect(() => {
isUpdatingRef.current = isUpdating; 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]); }, [isUpdating]);
useEffect(() => { useEffect(() => {
isNetworkErrorRef.current = isNetworkError; isNetworkErrorRef.current = isNetworkError;
}, [isNetworkError]); }, [isNetworkError]);
// Keep updateStartTime ref in sync
useEffect(() => {
updateStartTimeRef.current = updateStartTime;
}, [updateStartTime]);
// Clear reconnect interval when update completes or component unmounts // Clear reconnect interval when update completes or component unmounts
useEffect(() => { useEffect(() => {
// If we're no longer updating, clear the reconnect interval and reset subscription // If we're no longer updating, clear the reconnect interval and reset subscription
@@ -366,18 +248,8 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null; reconnectIntervalRef.current = null;
} }
// Clear reload timeout if update stops
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
// Reset subscription to prevent stale polling // Reset subscription to prevent stale polling
setTimeout(() => { setShouldSubscribe(false);
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 () => { return () => {
@@ -385,32 +257,9 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null; reconnectIntervalRef.current = null;
} }
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
}; };
}, [isUpdating]); }, [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 = () => { const handleUpdate = () => {
// Show confirmation modal instead of starting update directly // Show confirmation modal instead of starting update directly
setShowUpdateConfirmation(true); setShowUpdateConfirmation(true);
@@ -420,34 +269,19 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
// Close the confirmation modal // Close the confirmation modal
setShowUpdateConfirmation(false); setShowUpdateConfirmation(false);
// Start the actual update process // Start the actual update process
const sessionId = `update_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const startTime = Date.now();
setIsUpdating(true); setIsUpdating(true);
setUpdateResult(null); setUpdateResult(null);
setIsNetworkError(false); setIsNetworkError(false);
setUpdateLogs([]); setUpdateLogs([]);
setShouldSubscribe(false); // Will be set to true in mutation onSuccess setShouldSubscribe(false);
setUpdateStartTime(startTime); setUpdateStartTime(Date.now());
lastLogTimeRef.current = Date.now();
// 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 hasReloadedRef.current = false; // Reset reload flag when starting new update
// Clear any existing reconnect interval
// Clear any existing reconnect interval and reload timeout
if (reconnectIntervalRef.current) { if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current); clearInterval(reconnectIntervalRef.current);
reconnectIntervalRef.current = null; reconnectIntervalRef.current = null;
} }
if (reloadTimeoutRef.current) {
clearTimeout(reloadTimeoutRef.current);
reloadTimeoutRef.current = null;
}
executeUpdate.mutate(); executeUpdate.mutate();
}; };

View File

@@ -41,14 +41,10 @@ export async function POST(request: NextRequest) {
const sessionDurationDays = authConfig.sessionDurationDays; const sessionDurationDays = authConfig.sessionDurationDays;
const token = generateToken(username, sessionDurationDays); const token = generateToken(username, sessionDurationDays);
// Calculate expiration time for client
const expirationTime = Date.now() + (sessionDurationDays * 24 * 60 * 60 * 1000);
const response = NextResponse.json({ const response = NextResponse.json({
success: true, success: true,
message: 'Login successful', message: 'Login successful',
username, username
expirationTime
}); });
// Determine if request is over HTTPS // Determine if request is over HTTPS
@@ -58,7 +54,7 @@ export async function POST(request: NextRequest) {
response.cookies.set('auth-token', token, { response.cookies.set('auth-token', token, {
httpOnly: true, httpOnly: true,
secure: isSecure, // Only secure if actually over HTTPS secure: isSecure, // Only secure if actually over HTTPS
sameSite: 'lax', // Use lax for cross-origin navigation support sameSite: 'strict',
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
path: '/', path: '/',
}); });

View File

@@ -3,14 +3,6 @@ import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database-prisma'; import { getDatabase } from '../../../../../server/database-prisma';
import { getSSHService } from '../../../../../server/ssh-service'; 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( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
@@ -26,7 +18,7 @@ export async function GET(
} }
const db = getDatabase(); const db = getDatabase();
const server = await db.getServerById(id) as ServerData | null; const server = await db.getServerById(id);
if (!server) { if (!server) {
return NextResponse.json( return NextResponse.json(
@@ -36,14 +28,14 @@ export async function GET(
} }
// Only allow viewing public key if it was generated by the system // Only allow viewing public key if it was generated by the system
if (!server.key_generated) { if (!(server as any).key_generated) {
return NextResponse.json( return NextResponse.json(
{ error: 'Public key not available for user-provided keys' }, { error: 'Public key not available for user-provided keys' },
{ status: 403 } { status: 403 }
); );
} }
if (!server.ssh_key_path) { if (!(server as any).ssh_key_path) {
return NextResponse.json( return NextResponse.json(
{ error: 'SSH key path not found' }, { error: 'SSH key path not found' },
{ status: 404 } { status: 404 }
@@ -51,13 +43,13 @@ export async function GET(
} }
const sshService = getSSHService(); const sshService = getSSHService();
const publicKey = sshService.getPublicKey(server.ssh_key_path); const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
publicKey, publicKey,
serverName: server.name, serverName: (server as any).name,
serverIp: server.ip serverIp: (server as any).ip
}); });
} catch (error) { } catch (error) {
console.error('Error retrieving public key:', error); console.error('Error retrieving public key:', error);

View File

@@ -12,7 +12,7 @@ export const POST = withApiLogging(async function POST(_request: NextRequest) {
// Get the next available server ID for key file naming // Get the next available server ID for key file naming
const serverId = await db.getNextServerId(); const serverId = await db.getNextServerId();
const keyPair = await sshService.generateKeyPair(Number(serverId)); const keyPair = await sshService.generateKeyPair(serverId);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,

View File

@@ -4,25 +4,9 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { isValidCron } from 'cron-validator'; 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) { export async function POST(request: NextRequest) {
try { try {
const settings = await request.json() as AutoSyncSettings; const settings = await request.json();
if (!settings || typeof settings !== 'object') { if (!settings || typeof settings !== 'object') {
return NextResponse.json( return NextResponse.json(
@@ -70,7 +54,7 @@ export async function POST(request: NextRequest) {
// Validate predefined interval // Validate predefined interval
if (settings.syncIntervalType === 'predefined') { if (settings.syncIntervalType === 'predefined') {
const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours']; const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours'];
if (!settings.syncIntervalPredefined || !validIntervals.includes(settings.syncIntervalPredefined)) { if (!validIntervals.includes(settings.syncIntervalPredefined)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid predefined interval' }, { error: 'Invalid predefined interval' },
{ status: 400 } { status: 400 }
@@ -83,7 +67,7 @@ export async function POST(request: NextRequest) {
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') { if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') {
// Fallback to predefined if custom is selected but no cron expression // Fallback to predefined if custom is selected but no cron expression
settings.syncIntervalType = 'predefined'; settings.syncIntervalType = 'predefined';
settings.syncIntervalPredefined = settings.syncIntervalPredefined ?? '1hour'; settings.syncIntervalPredefined = settings.syncIntervalPredefined || '1hour';
settings.syncIntervalCron = ''; settings.syncIntervalCron = '';
} else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) { } else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
return NextResponse.json( return NextResponse.json(
@@ -125,7 +109,7 @@ export async function POST(request: NextRequest) {
); );
} }
} }
} catch { } catch (parseError) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid JSON format for Apprise URLs' }, { error: 'Invalid JSON format for Apprise URLs' },
{ status: 400 } { status: 400 }
@@ -146,15 +130,15 @@ export async function POST(request: NextRequest) {
const autoSyncSettings = { const autoSyncSettings = {
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false', 'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false',
'SYNC_INTERVAL_TYPE': settings.syncIntervalType, 'SYNC_INTERVAL_TYPE': settings.syncIntervalType,
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined ?? '', 'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined || '',
'SYNC_INTERVAL_CRON': settings.syncIntervalCron ?? '', 'SYNC_INTERVAL_CRON': settings.syncIntervalCron || '',
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false', 'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false',
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false', 'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false', 'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls ?? '[]'), 'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls || '[]'),
'LAST_AUTO_SYNC': settings.lastAutoSync ?? '', 'LAST_AUTO_SYNC': settings.lastAutoSync || '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError ?? '', 'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime ?? '' 'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
}; };
// Update or add each setting // Update or add each setting
@@ -176,27 +160,18 @@ export async function POST(request: NextRequest) {
// Reschedule auto-sync service with new settings // Reschedule auto-sync service with new settings
try { try {
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit'); const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit.js');
let autoSyncService = getAutoSyncService(); let autoSyncService = getAutoSyncService();
// If no global instance exists, create one // If no global instance exists, create one
if (!autoSyncService) { if (!autoSyncService) {
const { AutoSyncService } = await import('../../../../server/services/autoSyncService'); const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
autoSyncService = new AutoSyncService(); autoSyncService = new AutoSyncService();
setAutoSyncService(autoSyncService); setAutoSyncService(autoSyncService);
} }
// Update the global service instance with new settings // Update the global service instance with new settings
// Normalize appriseUrls to always be an array autoSyncService.saveSettings(settings);
const normalizedSettings = {
...settings,
appriseUrls: Array.isArray(settings.appriseUrls)
? settings.appriseUrls
: settings.appriseUrls
? [settings.appriseUrls]
: undefined
};
autoSyncService.saveSettings(normalizedSettings);
if (settings.autoSyncEnabled) { if (settings.autoSyncEnabled) {
autoSyncService.scheduleAutoSync(); autoSyncService.scheduleAutoSync();
@@ -205,7 +180,7 @@ export async function POST(request: NextRequest) {
// Ensure the service is completely stopped and won't restart // Ensure the service is completely stopped and won't restart
autoSyncService.isRunning = false; autoSyncService.isRunning = false;
// Also stop the global service instance if it exists // Also stop the global service instance if it exists
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit'); const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit.js');
stopGlobalAutoSync(); stopGlobalAutoSync();
} }
} catch (error) { } catch (error) {
@@ -256,21 +231,21 @@ export async function GET() {
autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true', autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true',
syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined', syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined',
syncIntervalPredefined: getEnvValue(envContent, 'SYNC_INTERVAL_PREDEFINED') || '1hour', 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', autoDownloadNew: getEnvValue(envContent, 'AUTO_DOWNLOAD_NEW') === 'true',
autoUpdateExisting: getEnvValue(envContent, 'AUTO_UPDATE_EXISTING') === 'true', autoUpdateExisting: getEnvValue(envContent, 'AUTO_UPDATE_EXISTING') === 'true',
notificationEnabled: getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true', notificationEnabled: getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true',
appriseUrls: (() => { appriseUrls: (() => {
try { try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') ?? '[]'; const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
return JSON.parse(urlsValue) as string[]; return JSON.parse(urlsValue);
} catch { } catch {
return []; return [];
} }
})(), })(),
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') ?? '', lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') || '',
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') ?? null, lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') || null,
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') ?? null lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') || null
}; };
return NextResponse.json({ settings }); return NextResponse.json({ settings });
@@ -300,8 +275,8 @@ async function handleTestNotification() {
const notificationEnabled = getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true'; const notificationEnabled = getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true';
const appriseUrls = (() => { const appriseUrls = (() => {
try { try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') ?? '[]'; const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
return JSON.parse(urlsValue) as string[]; return JSON.parse(urlsValue);
} catch { } catch {
return []; return [];
} }
@@ -314,7 +289,7 @@ async function handleTestNotification() {
); );
} }
if (!appriseUrls?.length) { if (!appriseUrls || appriseUrls.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'No Apprise URLs configured' }, { error: 'No Apprise URLs configured' },
{ status: 400 } { status: 400 }
@@ -322,7 +297,7 @@ async function handleTestNotification() {
} }
// Send test notification using the auto-sync service // Send test notification using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService'); const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const autoSyncService = new AutoSyncService(); const autoSyncService = new AutoSyncService();
const result = await autoSyncService.testNotification(); const result = await autoSyncService.testNotification();
@@ -370,11 +345,11 @@ async function handleManualSync() {
} }
// Trigger manual sync using the auto-sync service // Trigger manual sync using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService'); const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const autoSyncService = new AutoSyncService(); const autoSyncService = new AutoSyncService();
const result = await autoSyncService.executeAutoSync() as { success: boolean; message?: string } | null; const result = await autoSyncService.executeAutoSync() as any;
if (result?.success) { if (result && result.success) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Manual sync completed successfully', message: 'Manual sync completed successfully',
@@ -382,7 +357,7 @@ async function handleManualSync() {
}); });
} else { } else {
return NextResponse.json( return NextResponse.json(
{ error: result?.message ?? 'Unknown error' }, { error: result.message },
{ status: 500 } { status: 500 }
); );
} }
@@ -401,7 +376,7 @@ function getEnvValue(envContent: string, key: string): string {
const regex = new RegExp(`^${key}="(.+)"$`, 'm'); const regex = new RegExp(`^${key}="(.+)"$`, 'm');
let match = regex.exec(envContent); let match = regex.exec(envContent);
if (match?.[1]) { if (match && match[1]) {
let value = match[1]; let value = match[1];
// Remove extra quotes that might be around JSON values // Remove extra quotes that might be around JSON values
if (value.startsWith('"') && value.endsWith('"')) { if (value.startsWith('"') && value.endsWith('"')) {
@@ -413,7 +388,7 @@ function getEnvValue(envContent: string, key: string): string {
// Try to match without quotes (fallback) // Try to match without quotes (fallback)
const regexNoQuotes = new RegExp(`^${key}=([^\\s]*)$`, 'm'); const regexNoQuotes = new RegExp(`^${key}=([^\\s]*)$`, 'm');
match = regexNoQuotes.exec(envContent); match = regexNoQuotes.exec(envContent);
if (match?.[1]) { if (match && match[1]) {
return match[1]; return match[1];
} }

View File

@@ -1,71 +1,51 @@
"use client";
import { useState, useRef, useEffect } from "react"; 'use client';
import { ScriptsGrid } from "./_components/ScriptsGrid";
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab"; import { useState, useRef, useEffect } from 'react';
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab"; import { ScriptsGrid } from './_components/ScriptsGrid';
import { BackupsTab } from "./_components/BackupsTab"; import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { ResyncButton } from "./_components/ResyncButton"; import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
import { Terminal } from "./_components/Terminal"; import { BackupsTab } from './_components/BackupsTab';
import { ServerSettingsButton } from "./_components/ServerSettingsButton"; import { ResyncButton } from './_components/ResyncButton';
import { SettingsButton } from "./_components/SettingsButton"; import { Terminal } from './_components/Terminal';
import { HelpButton } from "./_components/HelpButton"; import { ServerSettingsButton } from './_components/ServerSettingsButton';
import { VersionDisplay } from "./_components/VersionDisplay"; import { SettingsButton } from './_components/SettingsButton';
import { ThemeToggle } from "./_components/ThemeToggle"; import { HelpButton } from './_components/HelpButton';
import { Button } from "./_components/ui/button"; import { VersionDisplay } from './_components/VersionDisplay';
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon"; import { ThemeToggle } from './_components/ThemeToggle';
import { import { Button } from './_components/ui/button';
ReleaseNotesModal, import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
getLastSeenVersion, import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
} from "./_components/ReleaseNotesModal"; import { Footer } from './_components/Footer';
import { Footer } from "./_components/Footer"; import { Package, HardDrive, FolderOpen, LogOut, Archive } from 'lucide-react';
import { Package, HardDrive, FolderOpen, LogOut, Archive } from "lucide-react"; import { api } from '~/trpc/react';
import { api } from "~/trpc/react"; import { useAuth } from './_components/AuthProvider';
import { useAuth } from "./_components/AuthProvider";
import type { Server } from "~/types/server";
import type { ScriptCard } from "~/types/script";
export default function Home() { export default function Home() {
const { isAuthenticated, logout } = useAuth(); const { isAuthenticated, logout } = useAuth();
const [runningScript, setRunningScript] = useState<{ const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
path: string; const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed' | 'backups'>(() => {
name: string; if (typeof window !== 'undefined') {
mode?: "local" | "ssh"; const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed' | 'backups';
server?: Server; return savedTab || 'scripts';
} | 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 [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>( const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
undefined,
);
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts // Fetch data for script counts
const { data: scriptCardsData } = const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
api.scripts.getScriptCardsWithCategories.useQuery(); const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: localScriptsData } = const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } =
api.installedScripts.getAllInstalledScripts.useQuery();
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery(); const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
const { data: versionData } = api.version.getCurrentVersion.useQuery(); const { data: versionData } = api.version.getCurrentVersion.useQuery();
// Save active tab to localStorage whenever it changes // Save active tab to localStorage whenever it changes
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== 'undefined') {
localStorage.setItem("activeTab", activeTab); localStorage.setItem('activeTab', activeTab);
} }
}, [activeTab]); }, [activeTab]);
@@ -74,12 +54,9 @@ export default function Home() {
if (versionData?.success && versionData.version) { if (versionData?.success && versionData.version) {
const currentVersion = versionData.version; const currentVersion = versionData.version;
const lastSeenVersion = getLastSeenVersion(); const lastSeenVersion = getLastSeenVersion();
// If we have a current version and either no last seen version or versions don't match // If we have a current version and either no last seen version or versions don't match
if ( if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
currentVersion &&
(!lastSeenVersion || currentVersion !== lastSeenVersion)
) {
setHighlightVersion(currentVersion); setHighlightVersion(currentVersion);
setReleaseNotesOpen(true); setReleaseNotesOpen(true);
} }
@@ -100,11 +77,11 @@ export default function Home() {
const scriptCounts = { const scriptCounts = {
available: (() => { available: (() => {
if (!scriptCardsData?.success) return 0; if (!scriptCardsData?.success) return 0;
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx) // Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
const scriptMap = new Map<string, ScriptCard>(); const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach((script: ScriptCard) => { scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) { if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence // Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) { if (!scriptMap.has(script.slug)) {
@@ -112,83 +89,69 @@ export default function Home() {
} }
} }
}); });
return scriptMap.size; return scriptMap.size;
})(), })(),
downloaded: (() => { downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0; if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// Helper to normalize identifiers for robust matching // Helper to normalize identifiers for robust matching
const normalizeId = (s?: string): string => const normalizeId = (s?: string): string => (s ?? '')
(s ?? "") .toLowerCase()
.toLowerCase() .replace(/\.(sh|bash|py|js|ts)$/g, '')
.replace(/\.(sh|bash|py|js|ts)$/g, "") .replace(/[^a-z0-9]+/g, '-')
.replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, '');
.replace(/^-+|-+$/g, "");
// First deduplicate GitHub scripts using Map by slug // First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, ScriptCard>(); const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach((script: ScriptCard) => { scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) { if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) { if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script); scriptMap.set(script.slug, script);
} }
} }
}); });
const deduplicatedGithubScripts = Array.from(scriptMap.values()); const deduplicatedGithubScripts = Array.from(scriptMap.values());
const localScripts = (localScriptsData.scripts ?? []) as Array<{ const localScripts = localScriptsData.scripts ?? [];
name?: string;
slug?: string;
}>;
// Count scripts that are both in deduplicated GitHub data and have local versions // Count scripts that are both in deduplicated GitHub data and have local versions
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid // Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
return deduplicatedGithubScripts.filter((script) => { return deduplicatedGithubScripts.filter(script => {
if (!script?.name) return false; if (!script?.name) return false;
// Check if there's a corresponding local script // Check if there's a corresponding local script
return localScripts.some((local) => { return localScripts.some(local => {
if (!local?.name) return false; if (!local?.name) return false;
// Primary: Exact slug-to-slug matching (most reliable) // Primary: Exact slug-to-slug matching (most reliable)
if (local.slug && script.slug) { if (local.slug && script.slug) {
if (local.slug.toLowerCase() === script.slug.toLowerCase()) { if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
return true; return true;
} }
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs) // Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
if ( if (normalizeId(local.slug) === normalizeId(script.slug)) {
normalizeId(local.slug ?? undefined) ===
normalizeId(script.slug ?? undefined)
) {
return true; return true;
} }
} }
// Secondary: Check install basenames (for edge cases where install script names differ from slugs) // Secondary: Check install basenames (for edge cases where install script names differ from slugs)
const normalizedLocal = normalizeId(local.name ?? undefined); const normalizedLocal = normalizeId(local.name);
const matchesInstallBasename = const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
script.install_basenames?.some(
(base) => normalizeId(String(base)) === normalizedLocal,
) ?? false;
if (matchesInstallBasename) return true; if (matchesInstallBasename) return true;
// Tertiary: Normalized filename to normalized slug matching // Tertiary: Normalized filename to normalized slug matching
if ( if (script.slug && normalizeId(local.name) === normalizeId(script.slug)) {
script.slug &&
normalizeId(local.name ?? undefined) ===
normalizeId(script.slug ?? undefined)
) {
return true; return true;
} }
return false; return false;
}); });
}).length; }).length;
})(), })(),
installed: installedScriptsData?.scripts?.length ?? 0, installed: installedScriptsData?.scripts?.length ?? 0,
backups: backupsData?.success ? backupsData.backups.length : 0, backups: backupsData?.success ? backupsData.backups.length : 0
}; };
const scrollToTerminal = () => { const scrollToTerminal = () => {
@@ -196,20 +159,15 @@ export default function Home() {
// Get the element's position and scroll with a small offset for better mobile experience // Get the element's position and scroll with a small offset for better mobile experience
const elementTop = terminalRef.current.offsetTop; const elementTop = terminalRef.current.offsetTop;
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
window.scrollTo({ window.scrollTo({
top: elementTop - offset, top: elementTop - offset,
behavior: "smooth", behavior: 'smooth'
}); });
} }
}; };
const handleRunScript = ( const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: Server,
) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server }); setRunningScript({ path: scriptPath, name: scriptName, mode, server });
// Scroll to terminal after a short delay to ensure it's rendered // Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100); setTimeout(scrollToTerminal, 100);
@@ -220,16 +178,16 @@ export default function Home() {
}; };
return ( return (
<main className="bg-background min-h-screen"> <main className="min-h-screen bg-background">
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8"> <div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
{/* Header */} {/* Header */}
<div className="mb-6 text-center sm:mb-8"> <div className="text-center mb-6 sm:mb-8">
<div className="mb-2 flex items-start justify-between"> <div className="flex justify-between items-start mb-2">
<div className="flex-1"></div> <div className="flex-1"></div>
<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"> <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">
<span className="break-words">PVE Scripts Management</span> <span className="break-words">PVE Scripts Management</span>
</h1> </h1>
<div className="flex flex-1 items-center justify-end gap-2"> <div className="flex-1 flex justify-end items-center gap-2">
{isAuthenticated && ( {isAuthenticated && (
<Button <Button
variant="ghost" variant="ghost"
@@ -245,9 +203,8 @@ export default function Home() {
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>
<p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base"> <p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
Manage and execute Proxmox helper scripts locally with live output Manage and execute Proxmox helper scripts locally with live output streaming
streaming
</p> </p>
<div className="flex justify-center px-2"> <div className="flex justify-center px-2">
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} /> <VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
@@ -256,7 +213,7 @@ export default function Home() {
{/* Controls */} {/* Controls */}
<div className="mb-6 sm:mb-8"> <div className="mb-6 sm:mb-8">
<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"> <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">
<ServerSettingsButton /> <ServerSettingsButton />
<SettingsButton /> <SettingsButton />
<ResyncButton /> <ResyncButton />
@@ -266,85 +223,72 @@ export default function Home() {
{/* Tab Navigation */} {/* Tab Navigation */}
<div className="mb-6 sm:mb-8"> <div className="mb-6 sm:mb-8">
<div className="border-border border-b"> <div className="border-b border-border">
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1"> <nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab("scripts")} 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 ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === "scripts" activeTab === 'scripts'
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none" ? '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" : 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`} }`}>
>
<Package className="h-4 w-4" /> <Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span> <span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span> <span className="sm:hidden">Available</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available} {scriptCounts.available}
</span> </span>
<ContextualHelpIcon <ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
section="available-scripts"
tooltip="Help with Available Scripts"
/>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab("downloaded")} 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 ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === "downloaded" activeTab === 'downloaded'
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none" ? '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" : 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`} }`}>
>
<HardDrive className="h-4 w-4" /> <HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span> <span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span> <span className="sm:hidden">Downloaded</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.downloaded} {scriptCounts.downloaded}
</span> </span>
<ContextualHelpIcon <ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
section="downloaded-scripts"
tooltip="Help with Downloaded Scripts"
/>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab("installed")} 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 ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === "installed" activeTab === 'installed'
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none" ? '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" : 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`} }`}>
>
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span> <span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span> <span className="sm:hidden">Installed</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.installed} {scriptCounts.installed}
</span> </span>
<ContextualHelpIcon <ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
section="installed-scripts"
tooltip="Help with Installed Scripts"
/>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab("backups")} 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 ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === "backups" activeTab === 'backups'
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none" ? '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" : 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`} }`}>
>
<Archive className="h-4 w-4" /> <Archive className="h-4 w-4" />
<span className="hidden sm:inline">Backups</span> <span className="hidden sm:inline">Backups</span>
<span className="sm:hidden">Backups</span> <span className="sm:hidden">Backups</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.backups} {scriptCounts.backups}
</span> </span>
</Button> </Button>
@@ -352,6 +296,8 @@ export default function Home() {
</div> </div>
</div> </div>
{/* Running Script Terminal */} {/* Running Script Terminal */}
{runningScript && ( {runningScript && (
<div ref={terminalRef} className="mb-8"> <div ref={terminalRef} className="mb-8">
@@ -365,17 +311,21 @@ export default function Home() {
)} )}
{/* Tab Content */} {/* Tab Content */}
{activeTab === "scripts" && ( {activeTab === 'scripts' && (
<ScriptsGrid onInstallScript={handleRunScript} /> <ScriptsGrid onInstallScript={handleRunScript} />
)} )}
{activeTab === "downloaded" && ( {activeTab === 'downloaded' && (
<DownloadedScriptsTab onInstallScript={handleRunScript} /> <DownloadedScriptsTab onInstallScript={handleRunScript} />
)} )}
{activeTab === "installed" && <InstalledScriptsTab />} {activeTab === 'installed' && (
<InstalledScriptsTab />
{activeTab === "backups" && <BackupsTab />} )}
{activeTab === 'backups' && (
<BackupsTab />
)}
</div> </div>
{/* Footer */} {/* Footer */}

View File

@@ -147,7 +147,7 @@ export function getAuthConfig(): {
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m; const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
const sessionDurationMatch = sessionDurationRegex.exec(envContent); const sessionDurationMatch = sessionDurationRegex.exec(envContent);
const sessionDurationDays = sessionDurationMatch 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; : DEFAULT_JWT_EXPIRY_DAYS;
const hasCredentials = !!(username && passwordHash); const hasCredentials = !!(username && passwordHash);

View File

@@ -29,7 +29,6 @@ export const backupsRouter = createTRPCRouter({
storage_name: string; storage_name: string;
storage_type: string; storage_type: string;
discovered_at: Date; discovered_at: Date;
server_id?: number;
server_name: string | null; server_name: string | null;
server_color: string | null; server_color: string | null;
}>; }>;
@@ -39,7 +38,7 @@ export const backupsRouter = createTRPCRouter({
if (backups.length === 0) continue; if (backups.length === 0) continue;
// Get hostname from first backup (all backups for same container should have same hostname) // 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({ result.push({
container_id: containerId, container_id: containerId,

View File

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

View File

@@ -56,7 +56,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
return { return {
success: true, success: true,
credentials: credentials.map((c: { id: number; server_id: number; storage_name: string; pbs_ip: string; pbs_datastore: string; pbs_fingerprint: string; pbs_password: string }) => ({ credentials: credentials.map(c => ({
id: c.id, id: c.id,
server_id: c.server_id, server_id: c.server_id,
storage_name: c.storage_name, storage_name: c.storage_name,
@@ -109,7 +109,7 @@ export const pbsCredentialsRouter = createTRPCRouter({
storage_name: input.storageName, storage_name: input.storageName,
pbs_ip: input.pbs_ip, pbs_ip: input.pbs_ip,
pbs_datastore: input.pbs_datastore, pbs_datastore: input.pbs_datastore,
pbs_password: passwordToSave ?? '', pbs_password: passwordToSave,
pbs_fingerprint: input.pbs_fingerprint, pbs_fingerprint: input.pbs_fingerprint,
}); });

View File

@@ -1,4 +1,3 @@
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { scriptManager } from "~/server/lib/scripts"; import { scriptManager } from "~/server/lib/scripts";
@@ -101,7 +100,7 @@ export const scriptsRouter = createTRPCRouter({
getAllScripts: publicProcedure getAllScripts: publicProcedure
.query(async () => { .query(async () => {
try { try {
const scripts = await localScriptsService.getAllScripts(); const scripts = await githubJsonService.getAllScripts();
return { success: true, scripts }; return { success: true, scripts };
} catch (error) { } catch (error) {
return { return {
@@ -178,7 +177,7 @@ export const scriptsRouter = createTRPCRouter({
const scripts = await localScriptsService.getAllScripts(); const scripts = await localScriptsService.getAllScripts();
// Create a set of enabled repository URLs for fast lookup // Create a set of enabled repository URLs for fast lookup
const enabledRepoUrls = new Set(enabledRepos.map((repo: { url: string }) => repo.url)); const enabledRepoUrls = new Set(enabledRepos.map(repo => repo.url));
// Create category ID to name mapping // Create category ID to name mapping
const categoryMap: Record<number, string> = {}; const categoryMap: Record<number, string> = {};
@@ -189,7 +188,7 @@ export const scriptsRouter = createTRPCRouter({
} }
// Enhance cards with category information and additional script data // Enhance cards with category information and additional script data
const cardsWithCategories = cards.map((card: ScriptCard) => { const cardsWithCategories = cards.map(card => {
const script = scripts.find(s => s.slug === card.slug); 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') ?? []; const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
@@ -226,7 +225,7 @@ export const scriptsRouter = createTRPCRouter({
// Filter cards to only include scripts from enabled repositories // Filter cards to only include scripts from enabled repositories
// For backward compatibility, include scripts without repository_url // For backward compatibility, include scripts without repository_url
const filteredCards = cardsWithCategories.filter((card: ScriptCard) => { const filteredCards = cardsWithCategories.filter(card => {
const repoUrl = card.repository_url; const repoUrl = card.repository_url;
// If script has no repository_url, include it for backward compatibility // If script has no repository_url, include it for backward compatibility

View File

@@ -1,5 +1,5 @@
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { readFile, writeFile, stat } from "fs/promises"; import { readFile, writeFile } from "fs/promises";
import { join } from "path"; import { join } from "path";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { env } from "~/env"; import { env } from "~/env";
@@ -176,21 +176,10 @@ export const versionRouter = createTRPCRouter({
return { return {
success: true, success: true,
logs: [], 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 logs = await readFile(logPath, 'utf-8');
const logLines = logs.split('\n') const logLines = logs.split('\n')
.filter(line => line.trim()) .filter(line => line.trim())
@@ -213,8 +202,7 @@ export const versionRouter = createTRPCRouter({
return { return {
success: true, success: true,
logs: logLines, logs: logLines,
isComplete, isComplete
logFileModifiedTime
}; };
} catch (error) { } catch (error) {
console.error('Error reading update logs:', error); console.error('Error reading update logs:', error);
@@ -222,8 +210,7 @@ export const versionRouter = createTRPCRouter({
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to read update logs', error: error instanceof Error ? error.message : 'Failed to read update logs',
logs: [], logs: [],
isComplete: false, isComplete: false
logFileModifiedTime: null
}; };
} }
}), }),

View File

@@ -9,10 +9,10 @@ class DatabaseServicePrisma {
} }
init() { init() {
// Ensure data/ssh-keys directory exists (recursive to create parent dirs) // Ensure data/ssh-keys directory exists
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys'); const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) { if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 }); mkdirSync(sshKeysDir, { mode: 0o700 });
} }
} }

View File

@@ -3,128 +3,26 @@ import { join } from 'path';
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs'; import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import type { CreateServerData } from '../types/server'; 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 { class DatabaseServicePrisma {
constructor() { constructor() {
this.init(); this.init();
} }
init(): void { init() {
// Ensure data/ssh-keys directory exists (recursive to create parent dirs) // Ensure data/ssh-keys directory exists
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys'); const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) { if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { recursive: true, mode: 0o700 }); mkdirSync(sshKeysDir, { mode: 0o700 });
} }
} }
// Server CRUD operations // Server CRUD operations
async createServer(serverData: CreateServerData): Promise<Server> { async createServer(serverData: CreateServerData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData; 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; const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : 22;
let ssh_key_path: string | null = null; let ssh_key_path = null;
// If using SSH key authentication, create persistent key file // If using SSH key authentication, create persistent key file
if (auth_type === 'key' && ssh_key) { if (auth_type === 'key' && ssh_key) {
@@ -132,7 +30,7 @@ class DatabaseServicePrisma {
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key); ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
} }
const result = await prisma.server.create({ return await prisma.server.create({
data: { data: {
name, name,
ip, ip,
@@ -147,30 +45,27 @@ class DatabaseServicePrisma {
color, color,
} }
}); });
return result as Server;
} }
async getAllServers(): Promise<Server[]> { async getAllServers() {
const result = await prisma.server.findMany({ return await prisma.server.findMany({
orderBy: { created_at: 'desc' } orderBy: { created_at: 'desc' }
}); });
return result as Server[];
} }
async getServerById(id: number): Promise<Server | null> { async getServerById(id: number) {
const result = await prisma.server.findUnique({ return await prisma.server.findUnique({
where: { id } where: { id }
}); });
return result as Server | null;
} }
async updateServer(id: number, serverData: CreateServerData): Promise<Server> { async updateServer(id: number, serverData: CreateServerData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData; 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; const normalizedPort = ssh_port !== undefined ? parseInt(String(ssh_port), 10) : undefined;
// Get existing server to check for key changes // Get existing server to check for key changes
const existingServer = await this.getServerById(id); const existingServer = await this.getServerById(id);
let ssh_key_path = existingServer?.ssh_key_path ?? null; let ssh_key_path = existingServer?.ssh_key_path;
// Handle SSH key changes // Handle SSH key changes
if (auth_type === 'key' && ssh_key) { if (auth_type === 'key' && ssh_key) {
@@ -206,7 +101,7 @@ class DatabaseServicePrisma {
ssh_key_path = null; ssh_key_path = null;
} }
const result = await prisma.server.update({ return await prisma.server.update({
where: { id }, where: { id },
data: { data: {
name, name,
@@ -222,10 +117,9 @@ class DatabaseServicePrisma {
color, color,
} }
}); });
return result as Server;
} }
async deleteServer(id: number): Promise<Server> { async deleteServer(id: number) {
// Get server info before deletion to clean up key files // Get server info before deletion to clean up key files
const server = await this.getServerById(id); const server = await this.getServerById(id);
@@ -242,10 +136,9 @@ class DatabaseServicePrisma {
} }
} }
const result = await prisma.server.delete({ return await prisma.server.delete({
where: { id } where: { id }
}); });
return result as Server;
} }
// Installed Scripts CRUD operations // Installed Scripts CRUD operations
@@ -259,10 +152,10 @@ class DatabaseServicePrisma {
output_log?: string; output_log?: string;
web_ui_ip?: string; web_ui_ip?: string;
web_ui_port?: number; 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; const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
const result = await prisma.installedScript.create({ return await prisma.installedScript.create({
data: { data: {
script_name, script_name,
script_path, script_path,
@@ -275,38 +168,34 @@ class DatabaseServicePrisma {
web_ui_port: web_ui_port ?? null, web_ui_port: web_ui_port ?? null,
} }
}); });
return result as InstalledScript;
} }
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> { async getAllInstalledScripts() {
const result = await prisma.installedScript.findMany({ return await prisma.installedScript.findMany({
include: { include: {
server: true server: true
}, },
orderBy: { installation_date: 'desc' } orderBy: { installation_date: 'desc' }
}); });
return result as InstalledScriptWithServer[];
} }
async getInstalledScriptById(id: number): Promise<InstalledScriptWithServer | null> { async getInstalledScriptById(id: number) {
const result = await prisma.installedScript.findUnique({ return await prisma.installedScript.findUnique({
where: { id }, where: { id },
include: { include: {
server: true server: true
} }
}); });
return result as InstalledScriptWithServer | null;
} }
async getInstalledScriptsByServer(server_id: number): Promise<InstalledScriptWithServer[]> { async getInstalledScriptsByServer(server_id: number) {
const result = await prisma.installedScript.findMany({ return await prisma.installedScript.findMany({
where: { server_id }, where: { server_id },
include: { include: {
server: true server: true
}, },
orderBy: { installation_date: 'desc' } orderBy: { installation_date: 'desc' }
}); });
return result as InstalledScriptWithServer[];
} }
async updateInstalledScript(id: number, updateData: { async updateInstalledScript(id: number, updateData: {
@@ -316,10 +205,17 @@ class DatabaseServicePrisma {
output_log?: string; output_log?: string;
web_ui_ip?: string; web_ui_ip?: string;
web_ui_port?: number; web_ui_port?: number;
}): Promise<InstalledScript | { changes: number }> { }) {
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData; const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
const updateFields: Prisma.InstalledScriptUpdateInput = {}; const updateFields: {
script_name?: string;
container_id?: string;
status?: 'in_progress' | 'success' | 'failed';
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
} = {};
if (script_name !== undefined) updateFields.script_name = script_name; if (script_name !== undefined) updateFields.script_name = script_name;
if (container_id !== undefined) updateFields.container_id = container_id; if (container_id !== undefined) updateFields.container_id = container_id;
if (status !== undefined) updateFields.status = status; if (status !== undefined) updateFields.status = status;
@@ -331,36 +227,33 @@ class DatabaseServicePrisma {
return { changes: 0 }; return { changes: 0 };
} }
const result = await prisma.installedScript.update({ return await prisma.installedScript.update({
where: { id }, where: { id },
data: updateFields data: updateFields
}); });
return result as InstalledScript;
} }
async deleteInstalledScript(id: number): Promise<InstalledScript> { async deleteInstalledScript(id: number) {
const result = await prisma.installedScript.delete({ return await prisma.installedScript.delete({
where: { id } where: { id }
}); });
return result as InstalledScript;
} }
async deleteInstalledScriptsByServer(server_id: number): Promise<{ count: number }> { async deleteInstalledScriptsByServer(server_id: number) {
const result = await prisma.installedScript.deleteMany({ return await prisma.installedScript.deleteMany({
where: { server_id } where: { server_id }
}); });
return result as { count: number };
} }
async getNextServerId(): Promise<number> { async getNextServerId() {
const result = await prisma.server.findFirst({ const result = await prisma.server.findFirst({
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
select: { id: true } select: { id: true }
}); });
return ((result as { id: number } | null)?.id ?? 0) + 1; return (result?.id ?? 0) + 1;
} }
createSSHKeyFile(serverId: number, sshKey: string): string { createSSHKeyFile(serverId: number, sshKey: string) {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys'); const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`); const keyPath = join(sshKeysDir, `server_${serverId}_key`);
@@ -373,18 +266,17 @@ class DatabaseServicePrisma {
} }
// LXC Config CRUD operations // LXC Config CRUD operations
async createLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> { async createLXCConfig(scriptId: number, configData: any) {
const result = await prisma.lXCConfig.create({ return await prisma.lXCConfig.create({
data: { data: {
installed_script_id: scriptId, installed_script_id: scriptId,
...configData ...configData
} }
}); });
return result as LXCConfig;
} }
async updateLXCConfig(scriptId: number, configData: LXCConfigInput): Promise<LXCConfig> { async updateLXCConfig(scriptId: number, configData: any) {
const result = await prisma.lXCConfig.upsert({ return await prisma.lXCConfig.upsert({
where: { installed_script_id: scriptId }, where: { installed_script_id: scriptId },
update: configData, update: configData,
create: { create: {
@@ -392,18 +284,16 @@ class DatabaseServicePrisma {
...configData ...configData
} }
}); });
return result as LXCConfig;
} }
async getLXCConfigByScriptId(scriptId: number): Promise<LXCConfig | null> { async getLXCConfigByScriptId(scriptId: number) {
const result = await prisma.lXCConfig.findUnique({ return await prisma.lXCConfig.findUnique({
where: { installed_script_id: scriptId } where: { installed_script_id: scriptId }
}); });
return result as LXCConfig | null;
} }
async deleteLXCConfig(scriptId: number): Promise<void> { async deleteLXCConfig(scriptId: number) {
await prisma.lXCConfig.delete({ return await prisma.lXCConfig.delete({
where: { installed_script_id: scriptId } where: { installed_script_id: scriptId }
}); });
} }
@@ -419,7 +309,7 @@ class DatabaseServicePrisma {
created_at?: Date; created_at?: Date;
storage_name: string; storage_name: string;
storage_type: 'local' | 'storage' | 'pbs'; storage_type: 'local' | 'storage' | 'pbs';
}): Promise<Backup> { }) {
// Find existing backup by container_id, server_id, and backup_path // Find existing backup by container_id, server_id, and backup_path
const existing = await prisma.backup.findFirst({ const existing = await prisma.backup.findFirst({
where: { where: {
@@ -427,11 +317,11 @@ class DatabaseServicePrisma {
server_id: backupData.server_id, server_id: backupData.server_id,
backup_path: backupData.backup_path, backup_path: backupData.backup_path,
}, },
}) as Backup | null; });
if (existing) { if (existing) {
// Update existing backup // Update existing backup
const result = await prisma.backup.update({ return await prisma.backup.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
hostname: backupData.hostname, hostname: backupData.hostname,
@@ -443,10 +333,9 @@ class DatabaseServicePrisma {
discovered_at: new Date(), discovered_at: new Date(),
}, },
}); });
return result as Backup;
} else { } else {
// Create new backup // Create new backup
const result = await prisma.backup.create({ return await prisma.backup.create({
data: { data: {
container_id: backupData.container_id, container_id: backupData.container_id,
server_id: backupData.server_id, server_id: backupData.server_id,
@@ -460,12 +349,11 @@ class DatabaseServicePrisma {
discovered_at: new Date(), discovered_at: new Date(),
}, },
}); });
return result as Backup;
} }
} }
async getAllBackups(): Promise<BackupWithServer[]> { async getAllBackups() {
const result = await prisma.backup.findMany({ return await prisma.backup.findMany({
include: { include: {
server: true, server: true,
}, },
@@ -474,43 +362,58 @@ class DatabaseServicePrisma {
{ created_at: 'desc' }, { created_at: 'desc' },
], ],
}); });
return result as BackupWithServer[];
} }
async getBackupById(id: number): Promise<BackupWithServer | null> { async getBackupById(id: number) {
const result = await prisma.backup.findUnique({ return await prisma.backup.findUnique({
where: { id }, where: { id },
include: { include: {
server: true, server: true,
}, },
}); });
return result as BackupWithServer | null;
} }
async getBackupsByContainerId(containerId: string): Promise<BackupWithServer[]> { async getBackupsByContainerId(containerId: string) {
const result = await prisma.backup.findMany({ return await prisma.backup.findMany({
where: { container_id: containerId }, where: { container_id: containerId },
include: { include: {
server: true, server: true,
}, },
orderBy: { created_at: 'desc' }, orderBy: { created_at: 'desc' },
}); });
return result as BackupWithServer[];
} }
async deleteBackupsForContainer(containerId: string, serverId: number): Promise<{ count: number }> { async deleteBackupsForContainer(containerId: string, serverId: number) {
const result = await prisma.backup.deleteMany({ return await prisma.backup.deleteMany({
where: { where: {
container_id: containerId, container_id: containerId,
server_id: serverId, server_id: serverId,
}, },
}); });
return result as { count: number };
} }
async getBackupsGroupedByContainer(): Promise<Map<string, BackupWithServer[]>> { 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;
}>>> {
const backups = await this.getAllBackups(); const backups = await this.getAllBackups();
const grouped = new Map<string, BackupWithServer[]>(); const grouped = new Map<string, typeof backups>();
for (const backup of backups) { for (const backup of backups) {
const key = backup.container_id; const key = backup.container_id;
@@ -531,8 +434,8 @@ class DatabaseServicePrisma {
pbs_datastore: string; pbs_datastore: string;
pbs_password: string; pbs_password: string;
pbs_fingerprint: string; pbs_fingerprint: string;
}): Promise<PBSStorageCredential> { }) {
const result = await prisma.pBSStorageCredential.upsert({ return await prisma.pBSStorageCredential.upsert({
where: { where: {
server_id_storage_name: { server_id_storage_name: {
server_id: credentialData.server_id, server_id: credentialData.server_id,
@@ -555,11 +458,10 @@ class DatabaseServicePrisma {
pbs_fingerprint: credentialData.pbs_fingerprint, pbs_fingerprint: credentialData.pbs_fingerprint,
}, },
}); });
return result as PBSStorageCredential;
} }
async getPBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential | null> { async getPBSCredential(serverId: number, storageName: string) {
const result = await prisma.pBSStorageCredential.findUnique({ return await prisma.pBSStorageCredential.findUnique({
where: { where: {
server_id_storage_name: { server_id_storage_name: {
server_id: serverId, server_id: serverId,
@@ -567,19 +469,17 @@ class DatabaseServicePrisma {
}, },
}, },
}); });
return result as PBSStorageCredential | null;
} }
async getPBSCredentialsByServer(serverId: number): Promise<PBSStorageCredential[]> { async getPBSCredentialsByServer(serverId: number) {
const result = await prisma.pBSStorageCredential.findMany({ return await prisma.pBSStorageCredential.findMany({
where: { server_id: serverId }, where: { server_id: serverId },
orderBy: { storage_name: 'asc' }, orderBy: { storage_name: 'asc' },
}); });
return result as PBSStorageCredential[];
} }
async deletePBSCredential(serverId: number, storageName: string): Promise<PBSStorageCredential> { async deletePBSCredential(serverId: number, storageName: string) {
const result = await prisma.pBSStorageCredential.delete({ return await prisma.pBSStorageCredential.delete({
where: { where: {
server_id_storage_name: { server_id_storage_name: {
server_id: serverId, server_id: serverId,
@@ -587,10 +487,9 @@ class DatabaseServicePrisma {
}, },
}, },
}); });
return result as PBSStorageCredential;
} }
async close(): Promise<void> { async close() {
await prisma.$disconnect(); await prisma.$disconnect();
} }
} }
@@ -598,7 +497,7 @@ class DatabaseServicePrisma {
// Singleton instance // Singleton instance
let dbInstance: DatabaseServicePrisma | null = null; let dbInstance: DatabaseServicePrisma | null = null;
export function getDatabase(): DatabaseServicePrisma { export function getDatabase() {
dbInstance ??= new DatabaseServicePrisma(); dbInstance ??= new DatabaseServicePrisma();
return dbInstance; return dbInstance;
} }

View File

@@ -1,11 +1,7 @@
import 'dotenv/config' import { PrismaClient } from '@prisma/client';
import { PrismaClient } from '../../prisma/generated/prisma/client.ts'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
const globalForPrisma = globalThis; const globalForPrisma = globalThis;
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL }); export const prisma = globalForPrisma.prisma ?? new PrismaClient();
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

View File

@@ -1,13 +1,10 @@
import 'dotenv/config' import { PrismaClient } from '@prisma/client';
import { PrismaClient } from '../../prisma/generated/prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
const globalForPrisma = globalThis as { prisma?: PrismaClient }; const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL! }); export const prisma = globalForPrisma.prisma ?? new PrismaClient({
export const prisma: PrismaClient = globalForPrisma.prisma ?? new PrismaClient({
adapter,
log: ['warn', 'error'] log: ['warn', 'error']
}); });

View File

@@ -1,23 +1,17 @@
import { AutoSyncService } from '../services/autoSyncService.js'; import { AutoSyncService } from '../services/autoSyncService.js';
import { repositoryService } from '../services/repositoryService.js'; import { repositoryService } from '../services/repositoryService.ts';
/** @type {AutoSyncService | null} */
let autoSyncService = null; let autoSyncService = null;
let isInitialized = false; let isInitialized = false;
/** /**
* Initialize default repositories * Initialize default repositories
* @returns {Promise<void>}
*/ */
export async function initializeRepositories() { export async function initializeRepositories() {
try { try {
console.log('Initializing default repositories...'); console.log('Initializing default repositories...');
if (repositoryService && repositoryService.initializeDefaultRepositories) { await repositoryService.initializeDefaultRepositories();
await repositoryService.initializeDefaultRepositories(); console.log('Default repositories initialized successfully');
console.log('Default repositories initialized successfully');
} else {
console.warn('Repository service not available, skipping repository initialization');
}
} catch (error) { } catch (error) {
console.error('Failed to initialize repositories:', error); console.error('Failed to initialize repositories:', error);
console.error('Error stack:', error.stack); console.error('Error stack:', error.stack);

View File

@@ -1,22 +1,7 @@
import { AutoSyncService } from '~/server/services/autoSyncService'; import { AutoSyncService } from '~/server/services/autoSyncService';
import { repositoryService } from '~/server/services/repositoryService';
let autoSyncService: AutoSyncService | null = null; 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 * Initialize auto-sync service and schedule cron job if enabled
*/ */

View File

@@ -272,12 +272,6 @@ export class AutoSyncService {
console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`); console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`);
/** @type {any} */
const cronOptions = {
scheduled: true,
timezone: 'UTC'
};
this.cronJob = cron.schedule(cronExpression, async () => { this.cronJob = cron.schedule(cronExpression, async () => {
// Check global lock first // Check global lock first
if (globalAutoSyncLock) { if (globalAutoSyncLock) {
@@ -306,7 +300,10 @@ export class AutoSyncService {
console.log('Starting scheduled auto-sync...'); console.log('Starting scheduled auto-sync...');
await this.executeAutoSync(); await this.executeAutoSync();
}, cronOptions); }, {
scheduled: true,
timezone: 'UTC'
});
console.log('Auto-sync cron job scheduled successfully'); console.log('Auto-sync cron job scheduled successfully');
} }
@@ -376,7 +373,7 @@ export class AutoSyncService {
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`); console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`);
// Get scripts only for the synced files // Get scripts only for the synced files
const localScriptsService = await import('./localScripts'); const localScriptsService = await import('./localScripts.js');
const syncedScripts = []; const syncedScripts = [];
for (const filename of syncResult.syncedFiles) { for (const filename of syncResult.syncedFiles) {

View File

@@ -1,4 +1,3 @@
/* 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 { getSSHExecutionService } from '../ssh-execution-service';
import { getStorageService } from './storageService'; import { getStorageService } from './storageService';
import { getDatabase } from '../database-prisma'; import { getDatabase } from '../database-prisma';
@@ -26,20 +25,20 @@ class BackupService {
let hostname = ''; let hostname = '';
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
'hostname', 'hostname',
(data: string) => { (data: string) => {
hostname += data; hostname += data;
}, },
(_error: string) => { (error: string) => {
reject(new Error(`Failed to get hostname: ${_error}`)); reject(new Error(`Failed to get hostname: ${error}`));
}, },
(_exitCode: number) => { (exitCode: number) => {
if (_exitCode === 0) { if (exitCode === 0) {
resolve(); resolve();
} else { } else {
reject(new Error(`hostname command failed with exit code ${_exitCode}`)); reject(new Error(`hostname command failed with exit code ${exitCode}`));
} }
} }
); );
@@ -62,19 +61,17 @@ class BackupService {
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
findCommand, findCommand,
(data: string) => { (data: string) => {
findOutput += data; findOutput += data;
}, },
(error: string) => { (error: string) => {
console.error('Error getting hostname:', error);
// Ignore errors - directory might not exist // Ignore errors - directory might not exist
resolve(); resolve();
}, },
(exitCode: number) => { (exitCode: number) => {
console.error('Error getting find command:', exitCode);
resolve(); resolve();
} }
); );
@@ -99,7 +96,7 @@ class BackupService {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
statCommand, statCommand,
(data: string) => { (data: string) => {
@@ -115,11 +112,11 @@ class BackupService {
]); ]);
const statParts = statOutput.trim().split('|'); 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]) { if (statParts.length >= 2 && statParts[0] && statParts[1]) {
const size = BigInt(statParts[0] ?? '0'); const size = BigInt(statParts[0] || '0');
const mtime = parseInt(statParts[1] ?? '0', 10); const mtime = parseInt(statParts[1] || '0', 10);
backups.push({ backups.push({
container_id: ctId, container_id: ctId,
@@ -147,9 +144,8 @@ class BackupService {
}); });
} }
} catch (error) { } catch (error) {
console.error('Error processing backup:', error);
// Still try to add the backup even if stat fails // Still try to add the backup even if stat fails
const fileName = backupPath.split('/').pop() ?? backupPath; const fileName = backupPath.split('/').pop() || backupPath;
backups.push({ backups.push({
container_id: ctId, container_id: ctId,
server_id: server.id, server_id: server.id,
@@ -186,18 +182,17 @@ class BackupService {
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
findCommand, findCommand,
(data: string) => { (data: string) => {
findOutput += data; findOutput += data;
}, },
(error: string) => { (error: string) => {
console.error('Error getting stat command:', error); // Ignore errors - storage might not be mounted
resolve(); resolve();
}, },
(exitCode: number) => { (exitCode: number) => {
console.error('Error getting stat command:', exitCode);
resolve(); resolve();
} }
); );
@@ -223,7 +218,7 @@ class BackupService {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
statCommand, statCommand,
(data: string) => { (data: string) => {
@@ -239,11 +234,11 @@ class BackupService {
]); ]);
const statParts = statOutput.trim().split('|'); 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]) { if (statParts.length >= 2 && statParts[0] && statParts[1]) {
const size = BigInt(statParts[0] ?? '0'); const size = BigInt(statParts[0] || '0');
const mtime = parseInt(statParts[1] ?? '0', 10); const mtime = parseInt(statParts[1] || '0', 10);
backups.push({ backups.push({
container_id: ctId, container_id: ctId,
@@ -275,7 +270,7 @@ class BackupService {
} catch (error) { } catch (error) {
console.error(`Error processing backup ${backupPath}:`, error); console.error(`Error processing backup ${backupPath}:`, error);
// Still try to add the backup even if stat fails // Still try to add the backup even if stat fails
const fileName = backupPath.split('/').pop() ?? backupPath; const fileName = backupPath.split('/').pop() || backupPath;
backups.push({ backups.push({
container_id: ctId, container_id: ctId,
server_id: server.id, server_id: server.id,
@@ -315,8 +310,8 @@ class BackupService {
const pbsInfo = storageService.getPBSStorageInfo(storage); const pbsInfo = storageService.getPBSStorageInfo(storage);
// Use IP and datastore from credentials (they override config if different) // Use IP and datastore from credentials (they override config if different)
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip; const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore; const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
if (!pbsIp || !pbsDatastore) { if (!pbsIp || !pbsDatastore) {
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`); console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
@@ -344,7 +339,7 @@ class BackupService {
try { try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
fullCommand, fullCommand,
(data: string) => { (data: string) => {
@@ -410,8 +405,8 @@ class BackupService {
const storageService = getStorageService(); const storageService = getStorageService();
const pbsInfo = storageService.getPBSStorageInfo(storage); const pbsInfo = storageService.getPBSStorageInfo(storage);
const pbsIp = credential.pbs_ip ?? pbsInfo.pbs_ip; const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
const pbsDatastore = credential.pbs_datastore ?? pbsInfo.pbs_datastore; const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
if (!pbsIp || !pbsDatastore) { if (!pbsIp || !pbsDatastore) {
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`); console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
@@ -430,8 +425,8 @@ class BackupService {
try { try {
// Add timeout to prevent hanging // Add timeout to prevent hanging
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve, reject) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
command, command,
(data: string) => { (data: string) => {
@@ -473,7 +468,7 @@ class BackupService {
if (line.includes('snapshot') && line.includes('size') && line.includes('files')) { if (line.includes('snapshot') && line.includes('size') && line.includes('files')) {
continue; // Skip header row continue; // Skip header row
} }
if (line.includes('═') || line.includes('─') || line.includes('│') && (/^[│═─╞╪╡├┼┤└┴┘]+$/.exec(line))) { if (line.includes('═') || line.includes('─') || line.includes('│') && line.match(/^[│═─╞╪╡├┼┤└┴┘]+$/)) {
continue; // Skip table separator lines continue; // Skip table separator lines
} }
if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) { if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) {
@@ -494,7 +489,7 @@ class BackupService {
// Extract snapshot name (last part after /) // Extract snapshot name (last part after /)
const snapshotParts = snapshotPath.split('/'); const snapshotParts = snapshotPath.split('/');
const snapshotName = snapshotParts[snapshotParts.length - 1] ?? snapshotPath; const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath;
if (!snapshotName) { if (!snapshotName) {
continue; // Skip if no snapshot name continue; // Skip if no snapshot name
@@ -502,12 +497,11 @@ class BackupService {
// Parse date from snapshot name (format: 2025-10-21T19:14:55Z) // Parse date from snapshot name (format: 2025-10-21T19:14:55Z)
let createdAt: Date | undefined; let createdAt: Date | undefined;
const dateMatch = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/.exec(snapshotName); const dateMatch = snapshotName.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/);
if (dateMatch?.[1]) { if (dateMatch && dateMatch[1]) {
try { try {
createdAt = new Date(dateMatch[1]); createdAt = new Date(dateMatch[1]);
} catch (e) { } catch (e) {
console.error('Error parsing date:', e);
// Invalid date, leave undefined // Invalid date, leave undefined
} }
} }
@@ -515,8 +509,8 @@ class BackupService {
// Parse size (convert MiB/GiB to bytes) // Parse size (convert MiB/GiB to bytes)
let size: bigint | undefined; let size: bigint | undefined;
if (sizeStr) { if (sizeStr) {
const sizeMatch = /([\d.]+)\s*(MiB|GiB|KiB|B)/i.exec(sizeStr); const sizeMatch = sizeStr.match(/([\d.]+)\s*(MiB|GiB|KiB|B)/i);
if (sizeMatch?.[1] && sizeMatch[2]) { if (sizeMatch && sizeMatch[1] && sizeMatch[2]) {
const sizeValue = parseFloat(sizeMatch[1]); const sizeValue = parseFloat(sizeMatch[1]);
const unit = sizeMatch[2].toUpperCase(); const unit = sizeMatch[2].toUpperCase();
let bytes = sizeValue; let bytes = sizeValue;
@@ -646,18 +640,18 @@ class BackupService {
if (!script.container_id || !script.server_id || !script.server) continue; if (!script.container_id || !script.server_id || !script.server) continue;
const containerId = script.container_id; const containerId = script.container_id;
const serverId = script.server_id;
const server = script.server as Server; const server = script.server as Server;
try { try {
// Get hostname from LXC config if available, otherwise use script name // 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 { try {
const lxcConfig = await db.getLXCConfigByScriptId(script.id); const lxcConfig = await db.getLXCConfigByScriptId(script.id);
if (lxcConfig?.hostname) { if (lxcConfig?.hostname) {
hostname = lxcConfig.hostname; hostname = lxcConfig.hostname;
} }
} catch (error) { } catch (error) {
console.error('Error getting LXC config:', error);
// LXC config might not exist, use script name // LXC config might not exist, use script name
console.debug(`No LXC config found for script ${script.id}, using script name as hostname`); console.debug(`No LXC config found for script ${script.id}, using script name as hostname`);
} }
@@ -688,7 +682,9 @@ class BackupService {
let backupServiceInstance: BackupService | null = null; let backupServiceInstance: BackupService | null = null;
export function getBackupService(): BackupService { export function getBackupService(): BackupService {
backupServiceInstance ??= new BackupService(); if (!backupServiceInstance) {
backupServiceInstance = new BackupService();
}
return backupServiceInstance; return backupServiceInstance;
} }

View File

@@ -1,428 +1,6 @@
// JavaScript wrapper for githubJsonService (for use with node server.js) // JavaScript wrapper for githubJsonService.ts
import { writeFile, mkdir, readdir, readFile } from 'fs/promises'; // This allows the JavaScript autoSyncService.js to import the TypeScript service
import { join } from 'path';
import { repositoryService } from './repositoryService.js';
// Get environment variables import { githubJsonService } from './githubJsonService.ts';
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
});
class GitHubJsonService { export { 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();

View File

@@ -1,9 +1,8 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import { writeFile, mkdir, readdir, readFile } from 'fs/promises'; import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import { env } from '../../env.js'; import { env } from '../../env.js';
import type { Script, ScriptCard, GitHubFile } from '../../types/script'; import type { Script, ScriptCard, GitHubFile } from '../../types/script';
import { repositoryService } from './repositoryService'; import { repositoryService } from './repositoryService.ts';
export class GitHubJsonService { export class GitHubJsonService {
private branch: string | null = null; private branch: string | null = null;
@@ -65,8 +64,7 @@ export class GitHubJsonService {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
} }
const data = await response.json(); return response.json() as Promise<T>;
return data as T;
} }
private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> { private async downloadJsonFile(repoUrl: string, filePath: string): Promise<Script> {
@@ -216,7 +214,9 @@ export class GitHubJsonService {
const script = JSON.parse(content) as Script; const script = JSON.parse(content) as Script;
// If script doesn't have repository_url, set it to main repo (for backward compatibility) // If script doesn't have repository_url, set it to main repo (for backward compatibility)
script.repository_url ??= env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE'; if (!script.repository_url) {
script.repository_url = env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
}
// Cache the script // Cache the script
this.scriptCache.set(slug, script); this.scriptCache.set(slug, script);
@@ -397,6 +397,7 @@ export class GitHubJsonService {
const filesToSync: GitHubFile[] = []; const filesToSync: GitHubFile[] = [];
for (const ghFile of githubFiles) { for (const ghFile of githubFiles) {
const slug = ghFile.name.replace('.json', '');
const localFilePath = join(this.localJsonDirectory!, ghFile.name); const localFilePath = join(this.localJsonDirectory!, ghFile.name);
let needsSync = false; let needsSync = false;

View File

@@ -0,0 +1,6 @@
// JavaScript wrapper for localScripts.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
import { localScriptsService } from './localScripts.ts';
export { localScriptsService };

View File

@@ -1,4 +1,3 @@
import { readFile, readdir, writeFile, mkdir } from 'fs/promises'; import { readFile, readdir, writeFile, mkdir } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import type { Script, ScriptCard } from '~/types/script'; import type { Script, ScriptCard } from '~/types/script';
@@ -96,7 +95,7 @@ export class LocalScriptsService {
let foundRepo: string | null = null; let foundRepo: string | null = null;
for (const repo of enabledRepos) { for (const repo of enabledRepos) {
try { try {
const { githubJsonService } = await import('./githubJsonService'); const { githubJsonService } = await import('./githubJsonService.js');
const repoScript = await githubJsonService.getScriptBySlug(slug, repo.url); const repoScript = await githubJsonService.getScriptBySlug(slug, repo.url);
if (repoScript) { if (repoScript) {
foundRepo = repo.url; foundRepo = repo.url;

View File

@@ -1,216 +0,0 @@
// 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();

View File

@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/prefer-regexp-exec */ import { prisma } from '../db.ts';
import { prisma } from '../db';
export class RepositoryService { export class RepositoryService {
/** /**
@@ -18,7 +17,7 @@ export class RepositoryService {
} }
}); });
const existingUrls = new Set(existingRepos.map((r: { url: string }) => r.url)); const existingUrls = new Set(existingRepos.map(r => r.url));
// Create main repo if it doesn't exist // Create main repo if it doesn't exist
if (!existingUrls.has(mainRepoUrl)) { if (!existingUrls.has(mainRepoUrl)) {

View File

@@ -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 { getSSHExecutionService } from '../ssh-execution-service';
import { getBackupService } from './backupService'; import { getBackupService } from './backupService';
import { getStorageService } from './storageService'; import { getStorageService } from './storageService';
import { getDatabase } from '../database-prisma'; import { getDatabase } from '../database-prisma';
import type { Server } from '~/types/server'; import type { Server } from '~/types/server';
import type { Storage } from './storageService'; import type { Storage } from './storageService';
import { writeFile } from 'fs/promises'; import { writeFile, readFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import { existsSync } from 'fs';
export interface RestoreProgress { export interface RestoreProgress {
step: string; step: string;
@@ -76,7 +76,7 @@ class RestoreService {
} }
return null; return null;
} catch { } catch (error) {
// Try fallback to database // Try fallback to database
try { try {
const installedScripts = await db.getAllInstalledScripts(); const installedScripts = await db.getAllInstalledScripts();
@@ -90,7 +90,7 @@ class RestoreService {
} }
} }
} }
} catch { } catch (dbError) {
// Ignore database error // Ignore database error
} }
return null; return null;
@@ -231,6 +231,7 @@ class RestoreService {
const snapshotNameForPath = snapshotName.replace(/:/g, '_'); const snapshotNameForPath = snapshotName.replace(/:/g, '_');
// Determine file extension - try common extensions // Determine file extension - try common extensions
const extensions = ['.tar', '.tar.zst', '.pxar'];
let downloadedPath = ''; let downloadedPath = '';
let downloadSuccess = false; let downloadSuccess = false;
@@ -407,7 +408,7 @@ class RestoreService {
const clearLogFile = async () => { const clearLogFile = async () => {
try { try {
await writeFile(logPath, '', 'utf-8'); await writeFile(logPath, '', 'utf-8');
} catch { } catch (error) {
// Ignore log file errors // Ignore log file errors
} }
}; };
@@ -417,7 +418,7 @@ class RestoreService {
try { try {
const logLine = `${message}\n`; const logLine = `${message}\n`;
await writeFile(logPath, logLine, { flag: 'a', encoding: 'utf-8' }); await writeFile(logPath, logLine, { flag: 'a', encoding: 'utf-8' });
} catch { } catch (error) {
// Ignore log file errors // Ignore log file errors
} }
}; };
@@ -451,12 +452,10 @@ class RestoreService {
} }
// Get server details // Get server details
const serverData = await db.getServerById(serverId); const server = await db.getServerById(serverId);
if (!serverData) { if (!server) {
throw new Error(`Server with ID ${serverId} not found`); 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 // Get rootfs storage
await addProgress('reading_config', 'Reading container configuration...'); await addProgress('reading_config', 'Reading container configuration...');
@@ -490,7 +489,7 @@ class RestoreService {
await addProgress('stopping', 'Stopping container...'); await addProgress('stopping', 'Stopping container...');
try { try {
await this.stopContainer(server, containerId); await this.stopContainer(server, containerId);
} catch { } catch (error) {
// Continue even if stop fails // Continue even if stop fails
} }
@@ -498,7 +497,7 @@ class RestoreService {
await addProgress('destroying', 'Destroying container...'); await addProgress('destroying', 'Destroying container...');
try { try {
await this.destroyContainer(server, containerId); await this.destroyContainer(server, containerId);
} catch { } catch (error) {
// Container might not exist, which is fine - continue with restore // Container might not exist, which is fine - continue with restore
await addProgress('skipping', 'Container does not exist or already destroyed, continuing...'); await addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
} }
@@ -560,4 +559,3 @@ export function getRestoreService(): RestoreService {
return restoreServiceInstance; return restoreServiceInstance;
} }

View File

@@ -4,23 +4,21 @@ import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
export class ScriptDownloaderService { export class ScriptDownloaderService {
constructor() { constructor() {
/** @type {string} */ this.scriptsDirectory = null;
this.scriptsDirectory = join(process.cwd(), 'scripts'); this.repoUrl = null;
/** @type {string} */
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
} }
initializeConfig() { initializeConfig() {
// Re-initialize if needed (for environment changes) if (this.scriptsDirectory === null) {
this.scriptsDirectory = join(process.cwd(), 'scripts'); this.scriptsDirectory = join(process.cwd(), 'scripts');
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE'; // Get REPO_URL from environment or use default
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 * Validates that a directory path doesn't contain nested directories with the same name
* (e.g., prevents ct/ct or install/install) * (e.g., prevents ct/ct or install/install)
* @param {string} dirPath - The directory path to validate
* @returns {boolean}
*/ */
validateDirectoryPath(dirPath) { validateDirectoryPath(dirPath) {
const normalizedPath = dirPath.replace(/\\/g, '/'); const normalizedPath = dirPath.replace(/\\/g, '/');
@@ -38,9 +36,6 @@ export class ScriptDownloaderService {
/** /**
* Validates that finalTargetDir doesn't contain nested directory names like ct/ct or install/install * 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) { validateTargetDir(targetDir, finalTargetDir) {
// Check if finalTargetDir contains nested directory names // Check if finalTargetDir contains nested directory names
@@ -58,11 +53,6 @@ export class ScriptDownloaderService {
return finalTargetDir; 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) { async ensureDirectoryExists(dirPath) {
// Validate the directory path to prevent nested directories with the same name // Validate the directory path to prevent nested directories with the same name
this.validateDirectoryPath(dirPath); this.validateDirectoryPath(dirPath);
@@ -71,7 +61,7 @@ export class ScriptDownloaderService {
console.log(`[Directory Creation] Ensuring directory exists: ${dirPath}`); console.log(`[Directory Creation] Ensuring directory exists: ${dirPath}`);
await mkdir(dirPath, { recursive: true }); await mkdir(dirPath, { recursive: true });
console.log(`[Directory Creation] Directory created/verified: ${dirPath}`); console.log(`[Directory Creation] Directory created/verified: ${dirPath}`);
} catch (/** @type {any} */ error) { } catch (error) {
if (error.code !== 'EEXIST') { if (error.code !== 'EEXIST') {
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, error.message); console.error(`[Directory Creation] Error creating directory ${dirPath}:`, error.message);
throw error; throw error;
@@ -81,11 +71,6 @@ export class ScriptDownloaderService {
} }
} }
/**
* Extract repository path from GitHub URL
* @param {string} repoUrl - The GitHub repository URL
* @returns {string}
*/
extractRepoPath(repoUrl) { extractRepoPath(repoUrl) {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl); const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!match) { if (!match) {
@@ -94,13 +79,6 @@ export class ScriptDownloaderService {
return `${match[1]}/${match[2]}`; 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') { async downloadFileFromGitHub(repoUrl, filePath, branch = 'main') {
this.initializeConfig(); this.initializeConfig();
if (!repoUrl) { if (!repoUrl) {
@@ -110,7 +88,6 @@ export class ScriptDownloaderService {
const repoPath = this.extractRepoPath(repoUrl); const repoPath = this.extractRepoPath(repoUrl);
const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`; const url = `https://raw.githubusercontent.com/${repoPath}/${branch}/${filePath}`;
/** @type {Record<string, string>} */
const headers = { const headers = {
'User-Agent': 'PVEScripts-Local/1.0', 'User-Agent': 'PVEScripts-Local/1.0',
}; };
@@ -129,11 +106,6 @@ export class ScriptDownloaderService {
return response.text(); return response.text();
} }
/**
* Get repository URL for a script
* @param {import('~/types/script').Script} script - The script object
* @returns {string}
*/
getRepoUrlForScript(script) { getRepoUrlForScript(script) {
// Use repository_url from script if available, otherwise fallback to env or default // Use repository_url from script if available, otherwise fallback to env or default
if (script.repository_url) { if (script.repository_url) {
@@ -143,11 +115,6 @@ export class ScriptDownloaderService {
return this.repoUrl; return this.repoUrl;
} }
/**
* Modify script content to use local paths
* @param {string} content - The script content
* @returns {string}
*/
modifyScriptContent(content) { modifyScriptContent(content) {
// Replace the build.func source line // Replace the build.func source line
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g; const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
@@ -156,15 +123,9 @@ export class ScriptDownloaderService {
return content.replace(oldPattern, newPattern); 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) { async loadScript(script) {
this.initializeConfig(); this.initializeConfig();
try { try {
/** @type {string[]} */
const files = []; const files = [];
const repoUrl = this.getRepoUrlForScript(script); const repoUrl = this.getRepoUrlForScript(script);
const branch = process.env.REPO_BRANCH || 'main'; const branch = process.env.REPO_BRANCH || 'main';
@@ -305,11 +266,6 @@ 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) { async isScriptDownloaded(script) {
if (!script.install_methods?.length) return false; if (!script.install_methods?.length) return false;
@@ -362,11 +318,6 @@ export class ScriptDownloaderService {
return true; 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) { async checkScriptExists(script) {
this.initializeConfig(); this.initializeConfig();
const files = []; const files = [];
@@ -465,11 +416,6 @@ 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) { async deleteScript(script) {
this.initializeConfig(); this.initializeConfig();
const deletedFiles = []; const deletedFiles = [];
@@ -521,14 +467,8 @@ 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) { async compareScriptContent(script) {
this.initializeConfig(); this.initializeConfig();
/** @type {string[]} */
const differences = []; const differences = [];
let hasDifferences = false; let hasDifferences = false;
const repoUrl = this.getRepoUrlForScript(script); const repoUrl = this.getRepoUrlForScript(script);
@@ -655,19 +595,12 @@ export class ScriptDownloaderService {
console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`); console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`);
return { hasDifferences, differences }; return { hasDifferences, differences };
} catch (/** @type {any} */ error) { } catch (error) {
console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error); console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error);
return { hasDifferences: false, differences: [], error: error.message }; 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) { async compareSingleFile(script, remotePath, filePath) {
try { try {
const localPath = join(this.scriptsDirectory, filePath); const localPath = join(this.scriptsDirectory, filePath);
@@ -703,19 +636,13 @@ export class ScriptDownloaderService {
} }
return { hasDifferences, filePath }; return { hasDifferences, filePath };
} catch (/** @type {any} */ error) { } catch (error) {
console.error(`[Comparison] Error comparing file ${filePath}:`, error.message); console.error(`[Comparison] Error comparing file ${filePath}:`, error.message);
// Return error information so it can be handled upstream // Return error information so it can be handled upstream
return { hasDifferences: false, filePath, error: error.message }; 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) { async getScriptDiff(script, filePath) {
this.initializeConfig(); this.initializeConfig();
try { try {
@@ -775,12 +702,6 @@ 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) { generateDiff(localContent, remoteContent) {
const localLines = localContent.split('\n'); const localLines = localContent.split('\n');
const remoteLines = remoteContent.split('\n'); const remoteLines = remoteContent.split('\n');

View File

@@ -1,4 +1,3 @@
/* 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 { getSSHExecutionService } from '../ssh-execution-service';
import type { Server } from '~/types/server'; import type { Server } from '~/types/server';
@@ -29,7 +28,8 @@ class StorageService {
let currentStorage: Partial<Storage> | null = null; let currentStorage: Partial<Storage> | null = null;
for (const rawLine of lines) { for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i];
if (!rawLine) continue; if (!rawLine) continue;
// Check if line is indented (has leading whitespace/tabs) BEFORE trimming // 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") // Check if this is a storage definition line (format: "type: name")
// Storage definitions are NOT indented // Storage definitions are NOT indented
if (!isIndented) { if (!isIndented) {
const storageMatch = /^(\w+):\s*(.+)$/.exec(line); const storageMatch = line.match(/^(\w+):\s*(.+)$/);
if (storageMatch?.[1] && storageMatch[2]) { if (storageMatch && storageMatch[1] && storageMatch[2]) {
// Save previous storage if exists // Save previous storage if exists
if (currentStorage?.name) { if (currentStorage && currentStorage.name) {
storages.push(this.finalizeStorage(currentStorage)); storages.push(this.finalizeStorage(currentStorage));
} }
@@ -65,9 +65,9 @@ class StorageService {
// Parse storage properties (indented lines - can be tabs or spaces) // Parse storage properties (indented lines - can be tabs or spaces)
if (currentStorage && isIndented) { if (currentStorage && isIndented) {
// Split on first whitespace (space or tab) to separate key and value // Split on first whitespace (space or tab) to separate key and value
const match = /^(\S+)\s+(.+)$/.exec(line); const match = line.match(/^(\S+)\s+(.+)$/);
if (match?.[1] && match[2]) { if (match && match[1] && match[2]) {
const key = match[1]; const key = match[1];
const value = match[2].trim(); const value = match[2].trim();
@@ -92,7 +92,7 @@ class StorageService {
} }
// Don't forget the last storage // Don't forget the last storage
if (currentStorage?.name) { if (currentStorage && currentStorage.name) {
storages.push(this.finalizeStorage(currentStorage)); storages.push(this.finalizeStorage(currentStorage));
} }
@@ -106,8 +106,8 @@ class StorageService {
return { return {
name: storage.name!, name: storage.name!,
type: storage.type!, type: storage.type!,
content: storage.content ?? [], content: storage.content || [],
supportsBackup: storage.supportsBackup ?? false, supportsBackup: storage.supportsBackup || false,
nodes: storage.nodes, nodes: storage.nodes,
...Object.fromEntries( ...Object.fromEntries(
Object.entries(storage).filter(([key]) => Object.entries(storage).filter(([key]) =>
@@ -138,7 +138,7 @@ class StorageService {
let configContent = ''; let configContent = '';
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshService.executeCommand( sshService.executeCommand(
server, server,
'cat /etc/pve/storage.cfg', 'cat /etc/pve/storage.cfg',
(data: string) => { (data: string) => {
@@ -191,8 +191,8 @@ class StorageService {
} }
return { return {
pbs_ip: (storage as any).server ?? null, pbs_ip: (storage as any).server || null,
pbs_datastore: (storage as any).datastore ?? null, pbs_datastore: (storage as any).datastore || null,
}; };
} }
@@ -215,7 +215,9 @@ class StorageService {
let storageServiceInstance: StorageService | null = null; let storageServiceInstance: StorageService | null = null;
export function getStorageService(): StorageService { export function getStorageService(): StorageService {
storageServiceInstance ??= new StorageService(); if (!storageServiceInstance) {
storageServiceInstance = new StorageService();
}
return storageServiceInstance; return storageServiceInstance;
} }

View File

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