feat(app-store): add port conflict indicator to deploy sheet (#533)

Show a pulsating warning dot next to host ports that are already in
use by a running container. Hovering over the dot reveals which
Sencho-managed stack or external app occupies the port.

Adds GET /api/ports/in-use endpoint that returns a map of bound host
ports with ownership info, and a getPortsInUse method on
DockerController that reuses the existing container-to-stack
resolution logic.
This commit is contained in:
Anso
2026-04-12 19:57:38 -04:00
committed by GitHub
parent 7a2099b56e
commit cd3d7b23be
5 changed files with 98 additions and 3 deletions
+13
View File
@@ -3167,6 +3167,19 @@ app.get('/api/containers', async (req: Request, res: Response) => {
}
});
app.get('/api/ports/in-use', async (req: Request, res: Response) => {
try {
const fsService = FileSystemService.getInstance(req.nodeId);
const stacks = await fsService.getStacks();
const dockerController = DockerController.getInstance(req.nodeId);
const portsInUse = await dockerController.getPortsInUse(stacks);
res.json(portsInUse);
} catch (error) {
console.error('[Ports] Failed to fetch ports in use:', error);
res.status(500).json({ error: 'Failed to fetch ports in use' });
}
});
// --- Label Routes (Skipper+) ---
app.get('/api/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
+41
View File
@@ -40,6 +40,11 @@ export interface ClassifiedImage {
managedStatus: 'managed' | 'unmanaged' | 'unused';
}
export interface PortInUseInfo {
stack: string | null;
container: string;
}
export interface ClassifiedVolume {
Name: string;
Driver: string;
@@ -662,6 +667,42 @@ class DockerController {
return result;
}
/**
* Returns a map of host ports currently bound by running containers,
* with ownership info (Sencho-managed stack name or external).
*/
public async getPortsInUse(knownStackNames: string[]): Promise<Record<number, PortInUseInfo>> {
const [allContainers, projectToStack] = await Promise.all([
this.docker.listContainers({ all: false }),
DockerController.resolveProjectNameMap(knownStackNames),
]);
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const knownStackSet = new Set(knownStackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
const result: Record<number, PortInUseInfo> = {};
for (const container of allContainers as Array<{ Names?: string[]; Labels?: Record<string, string>; Ports?: Array<{ PublicPort?: number }> }>) {
const stackDir = DockerController.resolveContainerStack(
container.Labels, projectToStack, knownStackSet, absDirToStack, resolvedBase,
);
const containerName = (container.Names?.[0] || '').replace(/^\//, '');
if (!Array.isArray(container.Ports)) continue;
for (const port of container.Ports) {
if (!port.PublicPort || port.PublicPort <= 0) continue;
// First container to claim a port wins (avoids overwrites)
if (result[port.PublicPort]) continue;
result[port.PublicPort] = { stack: stackDir, container: containerName };
}
}
return result;
}
public async getContainersByStack(stackName: string) {
const stackDir = path.join(COMPOSE_DIR, stackName);