mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
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:
@@ -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/),
|
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).
|
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)
|
## [0.36.0](https://github.com/AnsoCode/Sencho/compare/v0.35.0...v0.36.0) (2026-04-04)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2936,12 +2936,12 @@ app.get('/api/stacks/statuses', async (req: Request, res: Response) => {
|
|||||||
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
const stacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||||
const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, ''));
|
const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, ''));
|
||||||
const dockerController = DockerController.getInstance(req.nodeId);
|
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
|
// 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) {
|
for (const stack of stacks) {
|
||||||
const name = stack.replace(/\.(yml|yaml)$/, '');
|
const name = stack.replace(/\.(yml|yaml)$/, '');
|
||||||
result[stack] = statuses[name] ?? 'unknown';
|
result[stack] = bulkInfo[name] ?? { status: 'unknown' };
|
||||||
}
|
}
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -5203,7 +5203,8 @@ app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Resp
|
|||||||
try {
|
try {
|
||||||
const triggered = ImageUpdateService.getInstance().triggerManualRefresh();
|
const triggered = ImageUpdateService.getInstance().triggerManualRefresh();
|
||||||
if (!triggered) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ success: true, message: 'Image update check started in background.' });
|
res.json({ success: true, message: 'Image update check started in background.' });
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ import { NodeRegistry } from './NodeRegistry';
|
|||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
|
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 {
|
export interface ClassifiedImage {
|
||||||
Id: string;
|
Id: string;
|
||||||
RepoTags: string[];
|
RepoTags: string[];
|
||||||
@@ -364,27 +374,42 @@ class DockerController {
|
|||||||
return this.validateApiData<any[]>(containers);
|
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 allContainers = await this.docker.listContainers({ all: true });
|
||||||
const knownSet = new Set(stackNames);
|
const knownSet = new Set(stackNames);
|
||||||
|
|
||||||
const statuses: Record<string, 'running' | 'exited' | 'unknown'> = {};
|
const result: Record<string, BulkStackInfo> = {};
|
||||||
for (const name of stackNames) {
|
for (const name of stackNames) {
|
||||||
statuses[name] = 'unknown';
|
result[name] = { status: 'unknown' };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const container of allContainers as any[]) {
|
for (const container of allContainers as any[]) {
|
||||||
const project: string | undefined = container.Labels?.['com.docker.compose.project'];
|
const project: string | undefined = container.Labels?.['com.docker.compose.project'];
|
||||||
if (project && knownSet.has(project)) {
|
if (!project || !knownSet.has(project)) continue;
|
||||||
if (container.State === 'running') {
|
|
||||||
statuses[project] = 'running';
|
if (container.State === 'running') {
|
||||||
} else if (statuses[project] !== 'running') {
|
result[project].status = 'running';
|
||||||
statuses[project] = 'exited';
|
|
||||||
|
// 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) {
|
public async getContainersByStack(stackName: string) {
|
||||||
|
|||||||
@@ -159,9 +159,13 @@ export class ImageUpdateService {
|
|||||||
|
|
||||||
private static readonly INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
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 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
|
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() { }
|
private constructor() { }
|
||||||
|
|
||||||
public static getInstance(): ImageUpdateService {
|
public static getInstance(): ImageUpdateService {
|
||||||
@@ -186,7 +190,7 @@ export class ImageUpdateService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers a check immediately, unless one is already running or the
|
* 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.
|
* Returns false if rate-limited, true if a check was started.
|
||||||
*/
|
*/
|
||||||
public triggerManualRefresh(): boolean {
|
public triggerManualRefresh(): boolean {
|
||||||
|
|||||||
@@ -61,19 +61,33 @@ The stack header exposes four actions:
|
|||||||
|
|
||||||
## Stack context menu
|
## 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>
|
<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>
|
</Frame>
|
||||||
|
|
||||||
- **Alerts** - configure metric-based alerting rules for this stack
|
**Stopped stack:**
|
||||||
- **Check for updates** - manually trigger an image update check
|
|
||||||
- **Deploy** - run `docker compose up -d`
|
<Frame>
|
||||||
- **Stop** - stop all containers in the stack
|
<img src="/images/stack-management/context-menu-stopped.png" alt="Context menu for a stopped stack" />
|
||||||
- **Restart** - restart all containers in the stack
|
</Frame>
|
||||||
- **Update** - pull latest images and recreate containers
|
|
||||||
- **Delete** - stop and remove the stack (admin only)
|
### 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
|
## Scanning for stacks
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
@@ -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.
|
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.
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ interface StackStatus {
|
|||||||
[key: string]: 'running' | 'exited' | 'unknown';
|
[key: string]: 'running' | 'exited' | 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StackStatusInfo {
|
||||||
|
status: 'running' | 'exited' | 'unknown';
|
||||||
|
mainPort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback';
|
type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
@@ -138,6 +143,25 @@ export default function EditorLayout() {
|
|||||||
};
|
};
|
||||||
const isStackBusy = (stackFile: string) => stackFile in stackActions;
|
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 loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null;
|
||||||
|
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
@@ -157,6 +181,7 @@ export default function EditorLayout() {
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
||||||
|
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
|
||||||
const [labels, setLabels] = useState<StackLabel[]>([]);
|
const [labels, setLabels] = useState<StackLabel[]>([]);
|
||||||
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
|
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
|
||||||
const [activeLabelFilters, setActiveLabelFilters] = useState<Set<number>>(new Set());
|
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)
|
// 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');
|
const statusRes = await apiFetch('/stacks/statuses');
|
||||||
let bulkStatuses: Record<string, 'running' | 'exited' | 'unknown'> | null = null;
|
let bulkStatuses: Record<string, 'running' | 'exited' | 'unknown'> | null = null;
|
||||||
|
const bulkPorts: Record<string, number | undefined> = {};
|
||||||
if (statusRes.ok) {
|
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 {
|
} else {
|
||||||
// Fallback: query each stack individually (remote node may not have bulk endpoint)
|
// Fallback: query each stack individually (remote node may not have bulk endpoint)
|
||||||
const statusResults = await Promise.allSettled(
|
const statusResults = await Promise.allSettled(
|
||||||
@@ -342,6 +379,11 @@ export default function EditorLayout() {
|
|||||||
}
|
}
|
||||||
return next;
|
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();
|
refreshLabels();
|
||||||
return fileList;
|
return fileList;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1509,23 +1551,45 @@ export default function EditorLayout() {
|
|||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Check for updates
|
Check for updates
|
||||||
</DropdownMenuItem>
|
</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 />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
{(() => {
|
||||||
<Play className="h-4 w-4 mr-2" />
|
const { showDeploy, showStop, showRestart, showUpdate } = getStackMenuVisibility(file);
|
||||||
Deploy
|
const busy = isStackBusy(file);
|
||||||
</DropdownMenuItem>
|
return (
|
||||||
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
<>
|
||||||
<Square className="h-4 w-4 mr-2" />
|
{showDeploy && (
|
||||||
Stop
|
<DropdownMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
||||||
</DropdownMenuItem>
|
<Play className="h-4 w-4 mr-2" />
|
||||||
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
|
Deploy
|
||||||
<RotateCw className="h-4 w-4 mr-2" />
|
</DropdownMenuItem>
|
||||||
Restart
|
)}
|
||||||
</DropdownMenuItem>
|
{showStop && (
|
||||||
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
|
<DropdownMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Square className="h-4 w-4 mr-2" />
|
||||||
Update
|
Stop
|
||||||
</DropdownMenuItem>
|
</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)$/, '')) && (
|
{can('stack:delete', 'stack', file.replace(/\.(yml|yaml)$/, '')) && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -1598,23 +1662,45 @@ export default function EditorLayout() {
|
|||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Check for updates
|
Check for updates
|
||||||
</ContextMenuItem>
|
</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 />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
{(() => {
|
||||||
<Play className="h-4 w-4 mr-2" />
|
const { showDeploy, showStop, showRestart, showUpdate } = getStackMenuVisibility(file);
|
||||||
Deploy
|
const busy = isStackBusy(file);
|
||||||
</ContextMenuItem>
|
return (
|
||||||
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
<>
|
||||||
<Square className="h-4 w-4 mr-2" />
|
{showDeploy && (
|
||||||
Stop
|
<ContextMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
||||||
</ContextMenuItem>
|
<Play className="h-4 w-4 mr-2" />
|
||||||
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
|
Deploy
|
||||||
<RotateCw className="h-4 w-4 mr-2" />
|
</ContextMenuItem>
|
||||||
Restart
|
)}
|
||||||
</ContextMenuItem>
|
{showStop && (
|
||||||
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
|
<ContextMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Square className="h-4 w-4 mr-2" />
|
||||||
Update
|
Stop
|
||||||
</ContextMenuItem>
|
</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)$/, '')) && (
|
{can('stack:delete', 'stack', file.replace(/\.(yml|yaml)$/, '')) && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|||||||
Reference in New Issue
Block a user