mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
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:
@@ -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> => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user