Compare commits

...

7 Commits

Author SHA1 Message Date
Michel Roegl-Brunner
447332e558 fix: align toggle switches in repository settings
- Remove fixed label width from Toggle component
- Move delete button to left of toggle switches
- Add matching border/padding to 'Enable after adding' section to align with repository items
- Ensure all toggles have consistent right-side alignment
2025-11-29 16:12:20 +01:00
Michel Roegl-Brunner
9bbc19ae44 Merge pull request #358 from community-scripts/fix/357_356
fix: Add dynamic text to container control loading modal
2025-11-29 16:00:49 +01:00
Michel Roegl-Brunner
5564ae0393 fix: add dynamic text to container control loading modal
- Update LoadingModal to display action text (Starting/Stopping LXC/VM)
- Update handleStartStop to include container type (LXC/VM) in action text
- Show clear feedback when starting or stopping containers
2025-11-29 15:58:30 +01:00
Michel Roegl-Brunner
93d7842f6c feat: implement batch container type detection for performance optimization
- Add batchDetectContainerTypes() helper function that uses pct list and qm list to detect all container types in 2 SSH calls per server
- Update getAllInstalledScripts to use batch detection instead of individual isVM() calls per script
- Update getInstalledScriptsByServer to use batch detection for single server
- Update database queries to include lxc_config relation for fallback detection
- Fix isVM() function to properly default to LXC when VM config doesn't exist
- Significantly improves performance: reduces from N SSH calls per script to 2 SSH calls per server
2025-11-29 15:55:43 +01:00
Michel Roegl-Brunner
84c02048bc Fix a false detection as a VM when it is a LXC 2025-11-29 15:41:49 +01:00
github-actions[bot]
66a3bb3203 chore: add VERSION v0.5.0 (#355)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-28 14:04:05 +00:00
Michel Roegl-Brunner
0da802be42 Merge pull request #354 from community-scripts/bugfixing_bumps
Add TypeScript Runtime Support and add Prisma 7 Compatibility
2025-11-28 14:56:43 +01:00
9 changed files with 265 additions and 81 deletions

View File

@@ -1 +1 @@
0.4.13 0.5.0

7
package-lock.json generated
View File

@@ -64,6 +64,7 @@
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.14", "@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14", "@vitest/ui": "^4.0.14",
"baseline-browser-mapping": "^2.8.32",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.5", "eslint-config-next": "^16.0.5",
"jsdom": "^27.2.0", "jsdom": "^27.2.0",
@@ -5203,9 +5204,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.31", "version": "2.8.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {

View File

@@ -27,7 +27,6 @@
"dependencies": { "dependencies": {
"@prisma/adapter-better-sqlite3": "^7.0.1", "@prisma/adapter-better-sqlite3": "^7.0.1",
"@prisma/client": "^7.0.1", "@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.4",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
@@ -43,6 +42,7 @@
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.6",
"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.4.0",
@@ -80,6 +80,7 @@
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.14", "@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14", "@vitest/ui": "^4.0.14",
"baseline-browser-mapping": "^2.8.32",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.5", "eslint-config-next": "^16.0.5",
"jsdom": "^27.2.0", "jsdom": "^27.2.0",
@@ -88,9 +89,9 @@
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
"prisma": "^7.0.1", "prisma": "^7.0.1",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tsx": "^4.19.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"tsx": "^4.19.4",
"vitest": "^4.0.14" "vitest": "^4.0.14"
}, },
"ct3aMetadata": { "ct3aMetadata": {
@@ -103,4 +104,4 @@
"overrides": { "overrides": {
"prismjs": "^1.30.0" "prismjs": "^1.30.0"
} }
} }

View File

@@ -1630,7 +1630,7 @@ export function GeneralSettingsModal({
https://github.com/owner/repo) https://github.com/owner/repo)
</p> </p>
</div> </div>
<div className="flex items-center justify-between"> <div className="border-border flex items-center justify-between gap-3 rounded-lg border p-3">
<div> <div>
<p className="text-foreground text-sm font-medium"> <p className="text-foreground text-sm font-medium">
Enable after adding Enable after adding
@@ -1644,6 +1644,7 @@ export function GeneralSettingsModal({
onCheckedChange={setNewRepoEnabled} onCheckedChange={setNewRepoEnabled}
disabled={isAddingRepo} disabled={isAddingRepo}
label="Enable repository" label="Enable repository"
labelPosition="left"
/> />
</div> </div>
<Button <Button
@@ -1739,44 +1740,7 @@ export function GeneralSettingsModal({
{repo.enabled ? "• Enabled" : "• Disabled"} {repo.enabled ? "• Enabled" : "• Disabled"}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-shrink-0">
<Toggle
checked={repo.enabled}
onCheckedChange={async (enabled) => {
setMessage(null);
try {
const result =
await updateRepoMutation.mutateAsync({
id: repo.id,
enabled,
});
if (result.success) {
setMessage({
type: "success",
text: `Repository ${enabled ? "enabled" : "disabled"} successfully!`,
});
await refetchRepositories();
} else {
setMessage({
type: "error",
text:
result.error ??
"Failed to update repository",
});
}
} catch (error) {
setMessage({
type: "error",
text:
error instanceof Error
? error.message
: "Failed to update repository",
});
}
}}
disabled={updateRepoMutation.isPending}
label={repo.enabled ? "Disable" : "Enable"}
/>
<Button <Button
onClick={async () => { onClick={async () => {
if (!repo.is_removable) { if (!repo.is_removable) {
@@ -1837,6 +1801,44 @@ export function GeneralSettingsModal({
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
<Toggle
checked={repo.enabled}
onCheckedChange={async (enabled) => {
setMessage(null);
try {
const result =
await updateRepoMutation.mutateAsync({
id: repo.id,
enabled,
});
if (result.success) {
setMessage({
type: "success",
text: `Repository ${enabled ? "enabled" : "disabled"} successfully!`,
});
await refetchRepositories();
} else {
setMessage({
type: "error",
text:
result.error ??
"Failed to update repository",
});
}
} catch (error) {
setMessage({
type: "error",
text:
error instanceof Error
? error.message
: "Failed to update repository",
});
}
}}
disabled={updateRepoMutation.isPending}
label={repo.enabled ? "Disable" : "Enable"}
labelPosition="left"
/>
</div> </div>
</div> </div>
), ),

View File

@@ -709,6 +709,8 @@ export function InstalledScriptsTab() {
return; return;
} }
const containerType = script.is_vm ? "VM" : "LXC";
setConfirmationModal({ setConfirmationModal({
isOpen: true, isOpen: true,
variant: "simple", variant: "simple",
@@ -718,7 +720,7 @@ export function InstalledScriptsTab() {
setControllingScriptId(script.id); setControllingScriptId(script.id);
setLoadingModal({ setLoadingModal({
isOpen: true, isOpen: true,
action: `${action === "start" ? "Starting" : "Stopping"} container ${script.container_id}...`, action: `${action === "start" ? "Starting" : "Stopping"} ${containerType}...`,
}); });
void controlContainerMutation.mutate({ id: script.id, action }); void controlContainerMutation.mutate({ id: script.id, action });
setConfirmationModal(null); setConfirmationModal(null);

View File

@@ -16,7 +16,7 @@ interface LoadingModalProps {
export function LoadingModal({ export function LoadingModal({
isOpen, isOpen,
action: _action, action,
logs = [], logs = [],
isComplete = false, isComplete = false,
title, title,
@@ -64,6 +64,11 @@ export function LoadingModal({
)} )}
</div> </div>
{/* Action text - displayed prominently */}
{action && (
<p className="text-foreground text-base font-medium">{action}</p>
)}
{/* Static title text */} {/* Static title text */}
{title && <p className="text-muted-foreground text-sm">{title}</p>} {title && <p className="text-muted-foreground text-sm">{title}</p>}

View File

@@ -6,30 +6,40 @@ export interface ToggleProps
checked?: boolean; checked?: boolean;
onCheckedChange?: (checked: boolean) => void; onCheckedChange?: (checked: boolean) => void;
label?: string; label?: string;
labelPosition?: 'left' | 'right';
} }
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>( const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
({ className, checked, onCheckedChange, label, ...props }, ref) => { ({ className, checked, onCheckedChange, label, labelPosition = 'right', ...props }, ref) => {
const toggleSwitch = (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
ref={ref}
{...props}
/>
<div className={cn(
"w-11 h-6 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary/20 rounded-full peer after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out after:shadow-md transition-colors duration-300 ease-in-out border-2 border-gray-300 dark:border-gray-600",
checked
? "bg-blue-500 dark:bg-blue-600 after:translate-x-full"
: "bg-gray-300 dark:bg-gray-700",
className
)} />
</label>
);
return ( return (
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<label className="relative inline-flex items-center cursor-pointer"> {label && labelPosition === 'left' && (
<input <span className="text-sm font-medium text-foreground">
type="checkbox" {label}
className="sr-only" </span>
checked={checked} )}
onChange={(e) => onCheckedChange?.(e.target.checked)} {toggleSwitch}
ref={ref} {label && labelPosition === 'right' && (
{...props}
/>
<div className={cn(
"w-11 h-6 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary/20 rounded-full peer after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out after:shadow-md transition-colors duration-300 ease-in-out border-2 border-gray-300 dark:border-gray-600",
checked
? "bg-blue-500 dark:bg-blue-600 after:translate-x-full"
: "bg-gray-300 dark:bg-gray-700",
className
)} />
</label>
{label && (
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{label} {label}
</span> </span>

View File

@@ -458,14 +458,118 @@ async function isVM(scriptId: number, containerId: string, serverId: number | nu
); );
}); });
// 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) return false; // Always LXC since VM config doesn't exist
} catch (error) { } catch (error) {
console.error('Error determining container type:', error); console.error('Error determining container type:', error);
return false; // Default to LXC on error return false; // Default to LXC on error
} }
} }
// Helper function to batch detect container types for all containers on a server
// Returns a Map of container_id -> isVM (true for VM, false for LXC)
async function batchDetectContainerTypes(server: Server): Promise<Map<string, boolean>> {
const containerTypeMap = new Map<string, boolean>();
try {
// 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 first
const connectionTest = await sshService.testSSHConnection(server);
if (!(connectionTest as any).success) {
console.warn(`SSH connection failed for server ${server.name}, skipping batch detection`);
return containerTypeMap; // Return empty map if SSH fails
}
// Helper function to parse list output and extract IDs
const parseListOutput = (output: string): 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;
};
// Get containers from pct list
let pctOutput = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server,
'pct list',
(data: string) => {
pctOutput += data;
},
(error: string) => {
console.error(`pct list error for server ${server.name}:`, error);
// Don't reject, just continue - might be no containers
resolve();
},
(_exitCode: number) => {
resolve();
}
);
});
// Get VMs from qm list
let qmOutput = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server,
'qm list',
(data: string) => {
qmOutput += data;
},
(error: string) => {
console.error(`qm list error for server ${server.name}:`, error);
// Don't reject, just continue - might be no VMs
resolve();
},
(_exitCode: number) => {
resolve();
}
);
});
// Parse IDs from both lists
const containerIds = parseListOutput(pctOutput);
const vmIds = parseListOutput(qmOutput);
// Mark all LXC containers as false (not VM)
for (const id of containerIds) {
containerTypeMap.set(id, false);
}
// Mark all VMs as true (is VM)
for (const id of vmIds) {
containerTypeMap.set(id, true);
}
} catch (error) {
console.error(`Error in batchDetectContainerTypes for server ${server.name}:`, error);
// Return empty map on error - individual checks will fall back to isVM()
}
return containerTypeMap;
}
export const installedScriptsRouter = createTRPCRouter({ export const installedScriptsRouter = createTRPCRouter({
// Get all installed scripts // Get all installed scripts
@@ -475,13 +579,52 @@ export const installedScriptsRouter = createTRPCRouter({
const db = getDatabase(); const db = getDatabase();
const scripts = await db.getAllInstalledScripts(); const scripts = await db.getAllInstalledScripts();
// Group scripts by server_id for batch detection
const scriptsByServer = new Map<number, any[]>();
const serversMap = new Map<number, Server>();
for (const script of scripts) {
if (script.server_id && script.server) {
if (!scriptsByServer.has(script.server_id)) {
scriptsByServer.set(script.server_id, []);
serversMap.set(script.server_id, script.server as Server);
}
scriptsByServer.get(script.server_id)!.push(script);
}
}
// Batch detect container types for each server
const containerTypeMap = new Map<string, boolean>();
const batchDetectionPromises = Array.from(serversMap.entries()).map(async ([serverId, server]) => {
try {
const serverTypeMap = await batchDetectContainerTypes(server);
// Merge into main map with server-specific prefix to avoid collisions
// Actually, container IDs are unique across the cluster, so we can use them directly
for (const [containerId, isVM] of serverTypeMap.entries()) {
containerTypeMap.set(containerId, isVM);
}
} catch (error) {
console.error(`Error batch detecting types for server ${serverId}:`, error);
// Continue with other servers
}
});
await Promise.all(batchDetectionPromises);
// Transform scripts to flatten server data for frontend compatibility // Transform scripts to flatten server data for frontend compatibility
const transformedScripts = scripts.map((script: any) => {
const transformedScripts = await Promise.all(scripts.map(async (script: any) => { // Determine if it's a VM or LXC from batch detection map, fall back to isVM() if not found
// Determine if it's a VM or LXC
let is_vm = false; let is_vm = false;
if (script.container_id && script.server_id) { if (script.container_id && script.server_id) {
is_vm = await isVM(script.id, script.container_id, script.server_id); // First check if we have it in the batch detection map
if (containerTypeMap.has(script.container_id)) {
is_vm = containerTypeMap.get(script.container_id) ?? false;
} else {
// Fall back to checking LXCConfig in database (fast, no SSH needed)
// If LXCConfig exists, it's an LXC container
const hasLXCConfig = script.lxc_config !== null && script.lxc_config !== undefined;
is_vm = !hasLXCConfig; // If no LXCConfig, might be VM, but default to false for safety
}
} }
return { return {
@@ -498,7 +641,7 @@ export const installedScriptsRouter = createTRPCRouter({
is_vm, is_vm,
server: undefined // Remove nested server object server: undefined // Remove nested server object
}; };
})); });
return { return {
success: true, success: true,
@@ -522,13 +665,31 @@ export const installedScriptsRouter = createTRPCRouter({
const db = getDatabase(); const db = getDatabase();
const scripts = await db.getInstalledScriptsByServer(input.serverId); const scripts = await db.getInstalledScriptsByServer(input.serverId);
// Batch detect container types for this server
let containerTypeMap = new Map<string, boolean>();
if (scripts.length > 0 && scripts[0]?.server) {
try {
containerTypeMap = await batchDetectContainerTypes(scripts[0].server as Server);
} catch (error) {
console.error(`Error batch detecting types for server ${input.serverId}:`, error);
// Continue with empty map, will fall back to LXCConfig check
}
}
// Transform scripts to flatten server data for frontend compatibility // Transform scripts to flatten server data for frontend compatibility
const transformedScripts = scripts.map((script: any) => {
const transformedScripts = await Promise.all(scripts.map(async (script: any) => { // Determine if it's a VM or LXC from batch detection map, fall back to LXCConfig check if not found
// Determine if it's a VM or LXC
let is_vm = false; let is_vm = false;
if (script.container_id && script.server_id) { if (script.container_id && script.server_id) {
is_vm = await isVM(script.id, script.container_id, script.server_id); // First check if we have it in the batch detection map
if (containerTypeMap.has(script.container_id)) {
is_vm = containerTypeMap.get(script.container_id) ?? false;
} else {
// Fall back to checking LXCConfig in database (fast, no SSH needed)
// If LXCConfig exists, it's an LXC container
const hasLXCConfig = script.lxc_config !== null && script.lxc_config !== undefined;
is_vm = !hasLXCConfig; // If no LXCConfig, might be VM, but default to false for safety
}
} }
return { return {
@@ -545,7 +706,7 @@ export const installedScriptsRouter = createTRPCRouter({
is_vm, is_vm,
server: undefined // Remove nested server object server: undefined // Remove nested server object
}; };
})); });
return { return {
success: true, success: true,

View File

@@ -281,7 +281,8 @@ class DatabaseServicePrisma {
async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> { async getAllInstalledScripts(): Promise<InstalledScriptWithServer[]> {
const result = await prisma.installedScript.findMany({ const result = await prisma.installedScript.findMany({
include: { include: {
server: true server: true,
lxc_config: true
}, },
orderBy: { installation_date: 'desc' } orderBy: { installation_date: 'desc' }
}); });
@@ -302,7 +303,8 @@ class DatabaseServicePrisma {
const result = await prisma.installedScript.findMany({ const result = await prisma.installedScript.findMany({
where: { server_id }, where: { server_id },
include: { include: {
server: true server: true,
lxc_config: true
}, },
orderBy: { installation_date: 'desc' } orderBy: { installation_date: 'desc' }
}); });