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);
+6
View File
@@ -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".
<Frame>
<img src="/images/app-store/app-store-port-conflict.png" alt="Port conflict indicator showing port 8080 is used by heimdall" />
</Frame>
### 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+38 -3
View File
@@ -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<Record<string, string>>({});
const [customEnvs, setCustomEnvs] = useState<Array<{ key: string, value: string }>>([]);
const [newEnvKey, setNewEnvKey] = useState('');
const [portsInUse, setPortsInUse] = useState<Record<string, PortInUseInfo>>({});
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 (
<div key={idx} className="flex items-center space-x-2">
<Input
value={portVars[p] || ''}
value={hostPort}
onChange={(e) => {
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")}
/>
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
{conflict ? (
<CursorProvider>
<CursorContainer className="inline-flex items-center gap-2">
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
<span className="w-2 h-2 rounded-full bg-warning animate-pulse" />
</CursorContainer>
<Cursor>
<div className="h-2 w-2 rounded-full bg-brand" />
</Cursor>
<CursorFollow side="bottom" sideOffset={4} align="center" transition={{ stiffness: 400, damping: 40, bounce: 0 }}>
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
<span className="font-mono tabular-nums text-xs text-stat-value">
{conflict.stack !== null
? <>Port {hostPort} used by <span className="text-brand">{conflict.stack}</span></>
: <>Port {hostPort} used by an external app</>
}
</span>
</div>
</CursorFollow>
</CursorProvider>
) : (
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
)}
</div>
);
})}