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
+13
View File
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
* **stacks:** state-aware sidebar context menu — actions now adapt to stack state (running stacks show Stop/Restart/Update, stopped stacks show Deploy)
* **stacks:** "Open App" action in sidebar context menu — quickly open a stack's web interface without navigating to the detail view
* **stacks:** bulk status endpoint now returns detected web port per stack for Open App support
### Changed
* **updates:** reduced manual image update check cooldown from 10 minutes to 2 minutes
* **updates:** rate limit error message now dynamically derives from the configured cooldown constant
## [0.36.0](https://github.com/AnsoCode/Sencho/compare/v0.35.0...v0.36.0) (2026-04-04)
+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 {
+23 -9
View File
@@ -61,19 +61,33 @@ The stack header exposes four actions:
## Stack context menu
Right-click or use the **⋮** button on any stack in the sidebar to access:
Right-click or use the **⋮** button on any stack in the sidebar. The menu adapts to the stack's current state — only relevant actions are shown.
**Running stack:**
<Frame>
<img src="/images/stack-management/stack-context-menu.png" alt="Stack context menu" />
<img src="/images/stack-management/context-menu-running.png" alt="Context menu for a running stack" />
</Frame>
- **Alerts** - configure metric-based alerting rules for this stack
- **Check for updates** - manually trigger an image update check
- **Deploy** - run `docker compose up -d`
- **Stop** - stop all containers in the stack
- **Restart** - restart all containers in the stack
- **Update** - pull latest images and recreate containers
- **Delete** - stop and remove the stack (admin only)
**Stopped stack:**
<Frame>
<img src="/images/stack-management/context-menu-stopped.png" alt="Context menu for a stopped stack" />
</Frame>
### Available actions
- **Alerts** — configure metric-based alerting rules for this stack
- **Labels** — assign organizational labels (Pro)
- **Check for updates** — manually trigger an image update check
- **Open App** — open the stack's web interface in a new tab (only shown when the stack is running and exposes a web port)
- **Deploy** — start the stack (shown when stopped or not yet deployed)
- **Stop** — stop all containers (shown when running)
- **Restart** — restart all containers (shown when running)
- **Update** — pull latest images and recreate containers (shown when running)
- **Delete** — stop and remove the stack (admin only)
All actions are disabled while another operation is in progress on that stack.
## Scanning for stacks
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

+17
View File
@@ -294,3 +294,20 @@ docker logs -f sencho
```
The backend logs all route errors and service failures to stdout. This is the first place to look when the UI shows an error with no useful message.
## "Open App" doesn't appear in the context menu
**Symptom:** You right-click a stack in the sidebar but "Open App" is not listed.
**Cause:** The **Open App** option only appears when both conditions are met:
1. The stack is **running** (status shows UP).
2. At least one container in the stack has a **published port** (a port mapped to the host).
If your container uses host networking, doesn't expose ports, or only binds to internal Docker networks, the option won't appear. You can still access the app manually by checking the container's port mappings in the stack detail view.
## "Check for updates" shows rate-limit error
**Symptom:** Clicking "Check for updates" shows a toast error about waiting before refreshing.
**Cause:** Manual update checks are rate-limited to prevent excessive requests to container registries. Wait for the cooldown period to elapse before checking again. Automatic background checks run every 6 hours regardless of this limit.
+119 -33
View File
@@ -66,6 +66,11 @@ interface StackStatus {
[key: string]: 'running' | 'exited' | 'unknown';
}
interface StackStatusInfo {
status: 'running' | 'exited' | 'unknown';
mainPort?: number;
}
type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback';
interface Notification {
@@ -138,6 +143,25 @@ export default function EditorLayout() {
};
const isStackBusy = (stackFile: string) => stackFile in stackActions;
const getStackMenuVisibility = (file: string) => {
const status = stackStatuses[file];
return {
showDeploy: status !== 'running',
showStop: status === 'running',
showRestart: status === 'running',
showUpdate: status === 'running',
};
};
const openStackApp = (file: string) => {
const port = stackPorts[file];
if (!port) return;
const host = activeNode?.type === 'remote' && activeNode?.api_url
? new URL(activeNode.api_url).hostname
: window.location.hostname;
window.open(`http://${host}:${port}`, '_blank');
};
const loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null;
const [isScanning, setIsScanning] = useState(false);
@@ -157,6 +181,7 @@ export default function EditorLayout() {
const [isEditing, setIsEditing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
const [labels, setLabels] = useState<StackLabel[]>([]);
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [activeLabelFilters, setActiveLabelFilters] = useState<Set<number>>(new Set());
@@ -315,8 +340,20 @@ export default function EditorLayout() {
// Fetch all stack statuses in a single bulk call (falls back to per-stack queries for older remote nodes)
const statusRes = await apiFetch('/stacks/statuses');
let bulkStatuses: Record<string, 'running' | 'exited' | 'unknown'> | null = null;
const bulkPorts: Record<string, number | undefined> = {};
if (statusRes.ok) {
bulkStatuses = await statusRes.json();
const raw = await statusRes.json();
bulkStatuses = {};
// Handle both old format (plain string) and new format ({ status, mainPort })
for (const [key, val] of Object.entries(raw)) {
if (typeof val === 'string') {
bulkStatuses[key] = val as 'running' | 'exited' | 'unknown';
} else if (val && typeof val === 'object' && 'status' in val) {
const info = val as StackStatusInfo;
bulkStatuses[key] = info.status;
if (info.mainPort) bulkPorts[key] = info.mainPort;
}
}
} else {
// Fallback: query each stack individually (remote node may not have bulk endpoint)
const statusResults = await Promise.allSettled(
@@ -342,6 +379,11 @@ export default function EditorLayout() {
}
return next;
});
setStackPorts(prev => {
const keys = Object.keys(bulkPorts);
if (keys.length === Object.keys(prev).length && keys.every(k => prev[k] === bulkPorts[k])) return prev;
return bulkPorts;
});
refreshLabels();
return fileList;
} catch (error) {
@@ -1509,23 +1551,45 @@ export default function EditorLayout() {
<RefreshCw className="h-4 w-4 mr-2" />
Check for updates
</DropdownMenuItem>
{stackStatuses[file] === 'running' && stackPorts[file] && (
<DropdownMenuItem onClick={() => openStackApp(file)}>
<ExternalLink className="h-4 w-4 mr-2" strokeWidth={1.5} />
Open App
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
<Play className="h-4 w-4 mr-2" />
Deploy
</DropdownMenuItem>
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
<Square className="h-4 w-4 mr-2" />
Stop
</DropdownMenuItem>
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
<RotateCw className="h-4 w-4 mr-2" />
Restart
</DropdownMenuItem>
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
<Download className="h-4 w-4 mr-2" />
Update
</DropdownMenuItem>
{(() => {
const { showDeploy, showStop, showRestart, showUpdate } = getStackMenuVisibility(file);
const busy = isStackBusy(file);
return (
<>
{showDeploy && (
<DropdownMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
<Play className="h-4 w-4 mr-2" />
Deploy
</DropdownMenuItem>
)}
{showStop && (
<DropdownMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
<Square className="h-4 w-4 mr-2" />
Stop
</DropdownMenuItem>
)}
{showRestart && (
<DropdownMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
<RotateCw className="h-4 w-4 mr-2" />
Restart
</DropdownMenuItem>
)}
{showUpdate && (
<DropdownMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
<Download className="h-4 w-4 mr-2" />
Update
</DropdownMenuItem>
)}
</>
);
})()}
{can('stack:delete', 'stack', file.replace(/\.(yml|yaml)$/, '')) && (
<>
<DropdownMenuSeparator />
@@ -1598,23 +1662,45 @@ export default function EditorLayout() {
<RefreshCw className="h-4 w-4 mr-2" />
Check for updates
</ContextMenuItem>
{stackStatuses[file] === 'running' && stackPorts[file] && (
<ContextMenuItem onClick={() => openStackApp(file)}>
<ExternalLink className="h-4 w-4 mr-2" strokeWidth={1.5} />
Open App
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
<Play className="h-4 w-4 mr-2" />
Deploy
</ContextMenuItem>
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
<Square className="h-4 w-4 mr-2" />
Stop
</ContextMenuItem>
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
<RotateCw className="h-4 w-4 mr-2" />
Restart
</ContextMenuItem>
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
<Download className="h-4 w-4 mr-2" />
Update
</ContextMenuItem>
{(() => {
const { showDeploy, showStop, showRestart, showUpdate } = getStackMenuVisibility(file);
const busy = isStackBusy(file);
return (
<>
{showDeploy && (
<ContextMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
<Play className="h-4 w-4 mr-2" />
Deploy
</ContextMenuItem>
)}
{showStop && (
<ContextMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
<Square className="h-4 w-4 mr-2" />
Stop
</ContextMenuItem>
)}
{showRestart && (
<ContextMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
<RotateCw className="h-4 w-4 mr-2" />
Restart
</ContextMenuItem>
)}
{showUpdate && (
<ContextMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
<Download className="h-4 w-4 mr-2" />
Update
</ContextMenuItem>
)}
</>
);
})()}
{can('stack:delete', 'stack', file.replace(/\.(yml|yaml)$/, '')) && (
<>
<ContextMenuSeparator />