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