feat(stacks): state-aware sidebar context menu and Open App action (#368)

* feat(stacks): state-aware sidebar context menu and Open App action

- Context menu now adapts to stack state: running stacks show
  Stop/Restart/Update, stopped stacks show Deploy only
- Added "Open App" shortcut to open a stack's web UI directly
  from the sidebar (visible when running with a published port)
- Backend bulk status endpoint enriched with mainPort detection
- Reduced manual image update check cooldown from 10 to 2 minutes
- Rate limit error message now derives from the configured constant

* fix(stacks): use const for bulkPorts (prefer-const lint)
This commit is contained in:
Anso
2026-04-03 21:41:01 -04:00
committed by GitHub
parent f0d67a83a0
commit 55d3b8ca1d
9 changed files with 217 additions and 57 deletions
+5 -4
View File
@@ -2936,12 +2936,12 @@ app.get('/api/stacks/statuses', async (req: Request, res: Response) => {
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, ''));
const dockerController = DockerController.getInstance(req.nodeId);
const statuses = await dockerController.getBulkStackStatuses(stackNames);
const bulkInfo = await dockerController.getBulkStackStatuses(stackNames);
// Map back to filenames to match frontend expectations
const result: Record<string, 'running' | 'exited' | 'unknown'> = {};
const result: Record<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number }> = {};
for (const stack of stacks) {
const name = stack.replace(/\.(yml|yaml)$/, '');
result[stack] = statuses[name] ?? 'unknown';
result[stack] = bulkInfo[name] ?? { status: 'unknown' };
}
res.json(result);
} catch (error) {
@@ -5203,7 +5203,8 @@ app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Resp
try {
const triggered = ImageUpdateService.getInstance().triggerManualRefresh();
if (!triggered) {
res.status(429).json({ error: 'Rate limited. Please wait at least 10 minutes between manual refreshes.' });
const mins = ImageUpdateService.manualCooldownMinutes;
res.status(429).json({ error: `Rate limited. Please wait at least ${mins} minute${mins !== 1 ? 's' : ''} between manual refreshes.` });
return;
}
res.json({ success: true, message: 'Image update check started in background.' });
+34 -9
View File
@@ -11,6 +11,16 @@ import { NodeRegistry } from './NodeRegistry';
const execAsync = promisify(exec);
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
/** Common web-UI private ports, checked in priority order when detecting the main app port. */
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
/** Ports that should never be treated as the main app port. */
const IGNORE_PORTS = [1900, 53, 22];
export interface BulkStackInfo {
status: 'running' | 'exited' | 'unknown';
mainPort?: number;
}
export interface ClassifiedImage {
Id: string;
RepoTags: string[];
@@ -364,27 +374,42 @@ class DockerController {
return this.validateApiData<any[]>(containers);
}
public async getBulkStackStatuses(stackNames: string[]): Promise<Record<string, 'running' | 'exited' | 'unknown'>> {
public async getBulkStackStatuses(stackNames: string[]): Promise<Record<string, BulkStackInfo>> {
const allContainers = await this.docker.listContainers({ all: true });
const knownSet = new Set(stackNames);
const statuses: Record<string, 'running' | 'exited' | 'unknown'> = {};
const result: Record<string, BulkStackInfo> = {};
for (const name of stackNames) {
statuses[name] = 'unknown';
result[name] = { status: 'unknown' };
}
for (const container of allContainers as any[]) {
const project: string | undefined = container.Labels?.['com.docker.compose.project'];
if (project && knownSet.has(project)) {
if (container.State === 'running') {
statuses[project] = 'running';
} else if (statuses[project] !== 'running') {
statuses[project] = 'exited';
if (!project || !knownSet.has(project)) continue;
if (container.State === 'running') {
result[project].status = 'running';
// Detect main web port (first running container with a matchable port wins)
if (result[project].mainPort === undefined && Array.isArray(container.Ports) && container.Ports.length > 0) {
const ports = container.Ports as { PrivatePort?: number; PublicPort?: number }[];
let match = ports.find(p => p.PrivatePort && WEB_UI_PORTS.includes(p.PrivatePort));
if (!match) match = ports.find(p => p.PublicPort && WEB_UI_PORTS.includes(p.PublicPort));
if (!match) match = ports.find(p =>
(!p.PrivatePort || !IGNORE_PORTS.includes(p.PrivatePort)) &&
(!p.PublicPort || !IGNORE_PORTS.includes(p.PublicPort))
);
const chosen = match || ports[0];
if (chosen?.PublicPort) {
result[project].mainPort = chosen.PublicPort;
}
}
} else if (result[project].status !== 'running') {
result[project].status = 'exited';
}
}
return statuses;
return result;
}
public async getContainersByStack(stackName: string) {
+6 -2
View File
@@ -159,9 +159,13 @@ export class ImageUpdateService {
private static readonly INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
private static readonly STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 min after boot
private static readonly MANUAL_COOLDOWN_MS = 10 * 60 * 1000; // 10 min between manual triggers
private static readonly MANUAL_COOLDOWN_MS = 2 * 60 * 1000; // 2 min between manual triggers
private static readonly INTER_IMAGE_DELAY_MS = 300; // be polite to registries
public static get manualCooldownMinutes(): number {
return ImageUpdateService.MANUAL_COOLDOWN_MS / (60 * 1000);
}
private constructor() { }
public static getInstance(): ImageUpdateService {
@@ -186,7 +190,7 @@ export class ImageUpdateService {
/**
* Triggers a check immediately, unless one is already running or the
* 10-minute manual cooldown has not elapsed.
* manual cooldown (MANUAL_COOLDOWN_MS) has not elapsed.
* Returns false if rate-limited, true if a check was started.
*/
public triggerManualRefresh(): boolean {