diff --git a/backend/src/index.ts b/backend/src/index.ts index afba83b7..f31aff73 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 => { diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 676c8531..1191f482 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -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> { + 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 = {}; + + for (const container of allContainers as Array<{ Names?: string[]; Labels?: Record; 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); diff --git a/docs/features/app-store.mdx b/docs/features/app-store.mdx index 8b3ca619..2a3b0a90 100644 --- a/docs/features/app-store.mdx +++ b/docs/features/app-store.mdx @@ -48,6 +48,12 @@ Pre-filled with the template name in lowercase. You can change it; the same [nam For each exposed port, the sheet shows an editable **host port** field (left side) and the fixed **container port** (right side). Edit the host port to avoid conflicts with other services on the same machine. Ports must be in the valid range (1 to 65535); invalid values are highlighted and block deployment. +If a host port is already in use by a running container, a pulsating warning dot appears next to the port. Hover over the dot to see which application is using the port. If the application is managed by Sencho, the stack name is shown; otherwise the indicator reads "used by an external app". + + + Port conflict indicator showing port 8080 is used by heimdall + + ### Volumes For each container mount point, enter the **host path** where that data should be stored. Suggested defaults (e.g. `./config`, `./data`) are pre-filled. diff --git a/docs/images/app-store/app-store-port-conflict.png b/docs/images/app-store/app-store-port-conflict.png new file mode 100644 index 00000000..5459e824 Binary files /dev/null and b/docs/images/app-store/app-store-port-conflict.png differ diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 3d9a5701..b55edbdb 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -12,6 +12,7 @@ import { cn } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; +import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; function isValidPort(value: string): boolean { if (!value) return true; @@ -47,6 +48,11 @@ interface Template { source?: string; } +interface PortInUseInfo { + stack: string | null; + container: string; +} + interface AppStoreViewProps { onDeploySuccess: (stackName: string) => void; } @@ -70,6 +76,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { const [volVars, setVolVars] = useState>({}); const [customEnvs, setCustomEnvs] = useState>([]); const [newEnvKey, setNewEnvKey] = useState(''); + const [portsInUse, setPortsInUse] = useState>({}); const [newEnvVal, setNewEnvVal] = useState(''); useEffect(() => { @@ -139,6 +146,10 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { .replace(/^-|-$/g, ''); setStackName(defaultName); + // Fetch currently bound host ports for conflict detection + setPortsInUse({}); + apiFetch('/ports/in-use').then(r => r.ok ? r.json() : {}).then(setPortsInUse).catch(err => console.error('[AppStore] Failed to fetch ports in use:', err)); + setIsSheetOpen(true); }; @@ -424,17 +435,41 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { {selectedTemplate.ports.map((p, idx) => { const parts = p.split(':'); if (parts.length < 2) return null; + const hostPort = portVars[p] || ''; + const conflict = hostPort ? portsInUse[hostPort] : undefined; return (
{ const val = e.target.value.replace(/[^0-9]/g, ''); setPortVars(prev => ({ ...prev, [p]: val })); }} - className={cn("w-24 text-center font-mono", portVars[p] && !isValidPort(portVars[p]) && "border-destructive")} + className={cn("w-24 text-center font-mono", hostPort && !isValidPort(hostPort) && "border-destructive")} /> - : {parts[1]} + {conflict ? ( + + + : {parts[1]} + + + +
+ + +
+ + {conflict.stack !== null + ? <>Port {hostPort} used by {conflict.stack} + : <>Port {hostPort} used by an external app + } + +
+
+ + ) : ( + : {parts[1]} + )}
); })}