feat: Add container running status indicators with auto-refresh (#123)
* feat: add container running status indicators with auto-refresh - Add getContainerStatuses tRPC endpoint for checking container status - Support both local and SSH remote container status checking - Add 60-second auto-refresh for real-time status updates - Display green/red dots next to container IDs showing running/stopped status - Update both desktop table and mobile card views - Add proper error handling and fallback to 'unknown' status - Full TypeScript support with updated interfaces Resolves container status visibility in installed scripts tab * feat: update default sorting to group by server then container ID - Change default sort field from 'script_name' to 'server_name' - Implement multi-level sorting: server name first, then container ID - Use numeric sorting for container IDs for proper ordering - Maintain existing sorting behavior for other fields - Improves organization by grouping related containers together * feat: improve container status check triggering - Add multiple triggers for container status checks: - When component mounts (tab becomes active) - When scripts data loads - Every 60 seconds via interval - Add manual 'Refresh Container Status' button for on-demand checking - Add console logging for debugging status check triggers - Ensure status checks happen reliably when switching to installed scripts tab Fixes issue where status checks weren't triggering when tab loads * perf: optimize container status checking by batching per server - Group containers by server and make one pct list call per server - Replace individual container checks with batch processing - Parse all container statuses from single pct list response per server - Add proper TypeScript safety checks for undefined values - Significantly reduce SSH calls from N containers to 1 call per server This should dramatically speed up status loading for multiple containers on the same server * fix: resolve all linting errors - Fix React Hook dependency warnings by using useCallback and useMemo - Fix TypeScript unsafe argument errors with proper Server type - Fix nullish coalescing operator preferences - Fix floating promise warnings with void operator - All ESLint warnings and errors now resolved Ensures clean code quality for PR merge
This commit is contained in:
committed by
GitHub
parent
aaa09b4745
commit
53b5074f35
@@ -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<string>('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<number | null>(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<Record<string, 'running' | 'stopped' | 'unknown'>>({});
|
||||
|
||||
// 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")'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={fetchContainerStatuses}
|
||||
disabled={containerStatusMutation.isPending || scripts.length === 0}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
{containerStatusMutation.isPending ? '🔄 Checking...' : '🔄 Refresh Container Status'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Script Form */}
|
||||
@@ -810,7 +914,27 @@ export function InstalledScriptsTab() {
|
||||
/>
|
||||
) : (
|
||||
script.container_id ? (
|
||||
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
|
||||
{script.container_status && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.container_status === 'running' ? 'bg-green-500' :
|
||||
script.container_status === 'stopped' ? 'bg-red-500' :
|
||||
'bg-gray-400'
|
||||
}`}></div>
|
||||
<span className={`text-xs font-medium ${
|
||||
script.container_status === 'running' ? 'text-green-700 dark:text-green-300' :
|
||||
script.container_status === 'stopped' ? 'text-red-700 dark:text-red-300' :
|
||||
'text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
{script.container_status === 'running' ? 'Running' :
|
||||
script.container_status === 'stopped' ? 'Stopped' :
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm font-mono text-foreground break-all">
|
||||
{script.container_id ?? '-'}
|
||||
{script.container_id ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{script.container_id}</span>
|
||||
{script.container_status && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.container_status === 'running' ? 'bg-green-500' :
|
||||
script.container_status === 'stopped' ? 'bg-red-500' :
|
||||
'bg-gray-400'
|
||||
}`}></div>
|
||||
<span className={`text-xs font-medium ${
|
||||
script.container_status === 'running' ? 'text-green-700 dark:text-green-300' :
|
||||
script.container_status === 'stopped' ? 'text-red-700 dark:text-red-300' :
|
||||
'text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
{script.container_status === 'running' ? 'Running' :
|
||||
script.container_status === 'stopped' ? 'Stopped' :
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : '-'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<Record<string, 'running' | 'stopped' | 'unknown'>> {
|
||||
try {
|
||||
const { stdout } = await execAsync('pct list');
|
||||
const statusMap: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||
|
||||
// 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<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||
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<Record<string, 'running' | 'stopped' | 'unknown'>> {
|
||||
return new Promise((resolve) => {
|
||||
const sshService = getSSHExecutionService();
|
||||
const statusMap: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||
|
||||
// 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<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||
|
||||
// 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<string, Array<{containerId: string, server: any}>> = {};
|
||||
|
||||
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: {}
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user