diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx index b272f6d..9703ea1 100644 --- a/src/app/_components/InstalledScriptsTab.tsx +++ b/src/app/_components/InstalledScriptsTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { api } from '~/trpc/react'; import { Terminal } from './Terminal'; import { StatusBadge } from './Badge'; @@ -22,13 +22,14 @@ interface InstalledScript { installation_date: string; status: 'in_progress' | 'success' | 'failed'; output_log: string | null; + container_status?: 'running' | 'stopped' | 'unknown'; } export function InstalledScriptsTab() { const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all'); const [serverFilter, setServerFilter] = useState('all'); - const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('script_name'); + const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null); const [editingScriptId, setEditingScriptId] = useState(null); @@ -40,6 +41,7 @@ export function InstalledScriptsTab() { const [autoDetectStatus, setAutoDetectStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' }); const [cleanupStatus, setCleanupStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' }); const cleanupRunRef = useRef(false); + const [containerStatuses, setContainerStatuses] = useState>({}); // Fetch installed scripts const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery(); @@ -114,6 +116,18 @@ export function InstalledScriptsTab() { } }); + // Get container statuses mutation + const containerStatusMutation = api.installedScripts.getContainerStatuses.useMutation({ + onSuccess: (data) => { + if (data.success) { + setContainerStatuses(data.statusMap); + } + }, + onError: (error) => { + console.error('Error fetching container statuses:', error); + } + }); + // Cleanup orphaned scripts mutation const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({ onSuccess: (data) => { @@ -146,9 +160,33 @@ export function InstalledScriptsTab() { }); - const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? []; + const scripts: InstalledScript[] = useMemo(() => (scriptsData?.scripts as InstalledScript[]) ?? [], [scriptsData?.scripts]); const stats = statsData?.stats; + // Function to fetch container statuses + const fetchContainerStatuses = useCallback(() => { + console.log('Fetching container statuses...', { scriptsCount: scripts.length }); + const containersWithIds = scripts + .filter(script => script.container_id) + .map(script => ({ + containerId: script.container_id!, + serverId: script.server_id ?? undefined, + server: script.server_id ? { + id: script.server_id, + name: script.server_name!, + ip: script.server_ip!, + user: script.server_user!, + password: script.server_password!, + auth_type: 'password' // Default to password auth + } : undefined + })); + + console.log('Containers to check:', containersWithIds.length); + if (containersWithIds.length > 0) { + containerStatusMutation.mutate({ containers: containersWithIds }); + } + }, [scripts, containerStatusMutation]); + // Run cleanup when component mounts and scripts are loaded (only once) useEffect(() => { if (scripts.length > 0 && serversData?.servers && !cleanupMutation.isPending && !cleanupRunRef.current) { @@ -158,8 +196,43 @@ export function InstalledScriptsTab() { } }, [scripts.length, serversData?.servers, cleanupMutation]); + // Auto-refresh container statuses every 60 seconds + useEffect(() => { + if (scripts.length > 0) { + fetchContainerStatuses(); // Initial fetch + const interval = setInterval(fetchContainerStatuses, 60000); // Every 60 seconds + return () => clearInterval(interval); + } + }, [scripts.length, fetchContainerStatuses]); + + // Trigger status check when component becomes visible (tab is active) + useEffect(() => { + if (scripts.length > 0) { + // Small delay to ensure component is fully rendered + const timeoutId = setTimeout(() => { + fetchContainerStatuses(); + }, 100); + + return () => clearTimeout(timeoutId); + } + }, [scripts.length, fetchContainerStatuses]); // Include dependencies + + // Also trigger status check when scripts data loads + useEffect(() => { + if (scripts.length > 0 && !isLoading) { + console.log('Scripts data loaded, triggering status check'); + fetchContainerStatuses(); + } + }, [scriptsData, isLoading, scripts.length, fetchContainerStatuses]); + + // Update scripts with container statuses + const scriptsWithStatus = scripts.map(script => ({ + ...script, + container_status: script.container_id ? containerStatuses[script.container_id] ?? 'unknown' : undefined + })); + // Filter and sort scripts - const filteredScripts = scripts + const filteredScripts = scriptsWithStatus .filter((script: InstalledScript) => { const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) || (script.container_id?.includes(searchTerm) ?? false) || @@ -174,6 +247,33 @@ export function InstalledScriptsTab() { return matchesSearch && matchesStatus && matchesServer; }) .sort((a: InstalledScript, b: InstalledScript) => { + // Default sorting: group by server, then by container ID + if (sortField === 'server_name') { + const aServer = a.server_name ?? 'Local'; + const bServer = b.server_name ?? 'Local'; + + // First sort by server name + if (aServer !== bServer) { + return sortDirection === 'asc' ? + aServer.localeCompare(bServer) : + bServer.localeCompare(aServer); + } + + // If same server, sort by container ID + const aContainerId = a.container_id ?? ''; + const bContainerId = b.container_id ?? ''; + + if (aContainerId !== bContainerId) { + // Convert to numbers for proper numeric sorting + const aNum = parseInt(aContainerId) || 0; + const bNum = parseInt(bContainerId) || 0; + return sortDirection === 'asc' ? aNum - bNum : bNum - aNum; + } + + return 0; + } + + // For other sort fields, use the original logic let aValue: any; let bValue: any; @@ -186,10 +286,6 @@ export function InstalledScriptsTab() { aValue = a.container_id ?? ''; bValue = b.container_id ?? ''; break; - case 'server_name': - aValue = a.server_name ?? 'Local'; - bValue = b.server_name ?? 'Local'; - break; case 'status': aValue = a.status; bValue = b.status; @@ -419,6 +515,14 @@ export function InstalledScriptsTab() { > {showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'} + {/* Add Script Form */} @@ -810,7 +914,27 @@ export function InstalledScriptsTab() { /> ) : ( script.container_id ? ( - {String(script.container_id)} +
+ {String(script.container_id)} + {script.container_status && ( +
+
+ + {script.container_status === 'running' ? 'Running' : + script.container_status === 'stopped' ? 'Stopped' : + 'Unknown'} + +
+ )} +
) : ( - ) diff --git a/src/app/_components/ScriptInstallationCard.tsx b/src/app/_components/ScriptInstallationCard.tsx index 59a34cc..1852ddc 100644 --- a/src/app/_components/ScriptInstallationCard.tsx +++ b/src/app/_components/ScriptInstallationCard.tsx @@ -18,6 +18,7 @@ interface InstalledScript { installation_date: string; status: 'in_progress' | 'success' | 'failed'; output_log: string | null; + container_status?: 'running' | 'stopped' | 'unknown'; } interface ScriptInstallationCardProps { @@ -99,7 +100,29 @@ export function ScriptInstallationCard({ /> ) : (
- {script.container_id ?? '-'} + {script.container_id ? ( +
+ {script.container_id} + {script.container_status && ( +
+
+ + {script.container_status === 'running' ? 'Running' : + script.container_status === 'stopped' ? 'Stopped' : + 'Unknown'} + +
+ )} +
+ ) : '-'}
)} diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts index 71677c3..7a9718f 100644 --- a/src/server/api/routers/installedScripts.ts +++ b/src/server/api/routers/installedScripts.ts @@ -1,6 +1,100 @@ import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { getDatabase } from "~/server/database"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { getSSHExecutionService } from "~/server/ssh-execution-service"; +import type { Server } from "~/types/server"; + +const execAsync = promisify(exec); + +// Helper function to check local container statuses +async function getLocalContainerStatuses(containerIds: string[]): Promise> { + try { + const { stdout } = await execAsync('pct list'); + const statusMap: Record = {}; + + // Parse pct list output + const lines = stdout.trim().split('\n'); + const dataLines = lines.slice(1); // Skip header + + for (const line of dataLines) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 2) { + const vmid = parts[0]; + const status = parts[1]; + + if (vmid && containerIds.includes(vmid)) { + statusMap[vmid] = status === 'running' ? 'running' : 'stopped'; + } + } + } + + // Set unknown for containers not found in pct list + for (const containerId of containerIds) { + if (!(containerId in statusMap)) { + statusMap[containerId] = 'unknown'; + } + } + + return statusMap; + } catch (error) { + console.error('Error checking local container statuses:', error); + // Return unknown for all containers on error + const statusMap: Record = {}; + for (const containerId of containerIds) { + statusMap[containerId] = 'unknown'; + } + return statusMap; + } +} + +// Helper function to check remote container statuses (multiple containers per server) +async function getRemoteContainerStatuses(containerIds: string[], server: Server): Promise> { + return new Promise((resolve) => { + const sshService = getSSHExecutionService(); + const statusMap: Record = {}; + + // Initialize all containers as unknown + for (const containerId of containerIds) { + statusMap[containerId] = 'unknown'; + } + + void sshService.executeCommand( + server, + 'pct list', + (data: string) => { + // Parse the output to find all containers + const lines = data.trim().split('\n'); + const dataLines = lines.slice(1); // Skip header + + for (const line of dataLines) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 2) { + const vmid = parts[0]; + const status = parts[1]; + + // Check if this is one of the containers we're looking for + if (vmid && containerIds.includes(vmid)) { + statusMap[vmid] = status === 'running' ? 'running' : 'stopped'; + } + } + } + + resolve(statusMap); + }, + (error: string) => { + console.error(`Error checking remote containers on server ${server.name}:`, error); + resolve(statusMap); // Return the map with unknown statuses + }, + (exitCode: number) => { + if (exitCode !== 0) { + resolve(statusMap); // Return the map with unknown statuses + } + } + ); + }); +} export const installedScriptsRouter = createTRPCRouter({ // Get all installed scripts @@ -540,5 +634,94 @@ export const installedScriptsRouter = createTRPCRouter({ deletedScripts: [] }; } + }), + + // Get container running statuses + getContainerStatuses: publicProcedure + .input(z.object({ + containers: z.array(z.object({ + containerId: z.string(), + serverId: z.number().optional(), + server: z.object({ + id: z.number(), + name: z.string(), + ip: z.string(), + user: z.string(), + password: z.string(), + auth_type: z.string() + }).optional() + })) + })) + .mutation(async ({ input }) => { + try { + const { containers } = input; + const statusMap: Record = {}; + + // Group containers by server (local vs remote) + const localContainers: string[] = []; + const remoteContainers: Array<{containerId: string, server: any}> = []; + + for (const container of containers) { + if (!container.serverId || !container.server) { + localContainers.push(container.containerId); + } else { + remoteContainers.push({ + containerId: container.containerId, + server: container.server + }); + } + } + + // Check local containers + if (localContainers.length > 0) { + const localStatuses = await getLocalContainerStatuses(localContainers); + Object.assign(statusMap, localStatuses); + } + + // Check remote containers - group by server and make one call per server + const serverGroups: Record> = {}; + + for (const { containerId, server } of remoteContainers) { + const serverKey = `${server.id}-${server.name}`; + serverGroups[serverKey] ??= []; + serverGroups[serverKey].push({ containerId, server }); + } + + // Make one call per server + for (const [serverKey, containers] of Object.entries(serverGroups)) { + try { + if (containers.length === 0) continue; + + const server = containers[0]?.server; + if (!server) continue; + + const containerIds = containers.map(c => c.containerId).filter(Boolean); + const serverStatuses = await getRemoteContainerStatuses(containerIds, server as Server); + + // Merge the results + Object.assign(statusMap, serverStatuses); + } catch (error) { + console.error(`Error checking statuses for server ${serverKey}:`, error); + // Set all containers for this server to unknown + for (const container of containers) { + if (container.containerId) { + statusMap[container.containerId] = 'unknown'; + } + } + } + } + + return { + success: true, + statusMap + }; + } catch (error) { + console.error('Error in getContainerStatuses:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch container statuses', + statusMap: {} + }; + } }) });