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:
Michel Roegl-Brunner
2025-10-13 15:30:04 +02:00
committed by GitHub
parent aaa09b4745
commit 53b5074f35
3 changed files with 340 additions and 10 deletions

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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: {}
};
}
})
});