mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(stacks): per-stack action tracking, optimistic status, and bulk status endpoint (#362)
* feat(stacks): per-stack action tracking, optimistic status, and bulk status endpoint Replace global loadingAction mutex with per-stack tracking so users can fire actions on multiple stacks concurrently. Add optimistic status updates to fix sidebar showing "--" after stop/start. Add bulk GET /api/stacks/statuses endpoint using a single docker.listContainers call instead of N docker compose ps invocations (~21s → ~110ms for 3 stacks). Falls back to per-stack queries for remote nodes on older versions. * fix(stacks): remove stale 'start' action check from deploy button label
This commit is contained in:
@@ -4,6 +4,20 @@ 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:** bulk `/api/stacks/statuses` endpoint — fetches all stack statuses in a single Docker API call instead of N individual `docker compose ps` invocations. Falls back to per-stack queries for remote nodes on older versions.
|
||||
|
||||
### Fixed
|
||||
|
||||
* **stacks:** sidebar status indicators showing "--" (unknown) after stopping a stack instead of "DN". Root cause was a race condition where `refreshStacks` queried container state before Docker had fully transitioned containers.
|
||||
|
||||
### Changed
|
||||
|
||||
* **stacks:** stack actions (deploy, stop, restart, update) are now tracked per-stack instead of globally. Users can fire actions on multiple stacks concurrently without the UI blocking. Sidebar shows a spinner per stack during in-flight actions.
|
||||
|
||||
## [0.34.0](https://github.com/AnsoCode/Sencho/compare/v0.33.1...v0.34.0) (2026-04-03)
|
||||
|
||||
|
||||
|
||||
@@ -2917,6 +2917,25 @@ app.get('/api/stacks', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/statuses', async (req: Request, res: Response) => {
|
||||
try {
|
||||
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);
|
||||
// Map back to filenames to match frontend expectations
|
||||
const result: Record<string, 'running' | 'exited' | 'unknown'> = {};
|
||||
for (const stack of stacks) {
|
||||
const name = stack.replace(/\.(yml|yaml)$/, '');
|
||||
result[stack] = statuses[name] ?? 'unknown';
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stack statuses:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch stack statuses' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
|
||||
@@ -364,6 +364,29 @@ class DockerController {
|
||||
return this.validateApiData<any[]>(containers);
|
||||
}
|
||||
|
||||
public async getBulkStackStatuses(stackNames: string[]): Promise<Record<string, 'running' | 'exited' | 'unknown'>> {
|
||||
const allContainers = await this.docker.listContainers({ all: true });
|
||||
const knownSet = new Set(stackNames);
|
||||
|
||||
const statuses: Record<string, 'running' | 'exited' | 'unknown'> = {};
|
||||
for (const name of stackNames) {
|
||||
statuses[name] = '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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
public async getContainersByStack(stackName: string) {
|
||||
const stackDir = path.join(COMPOSE_DIR, stackName);
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ interface StackStatus {
|
||||
[key: string]: 'running' | 'exited' | 'unknown';
|
||||
}
|
||||
|
||||
type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback';
|
||||
|
||||
interface Notification {
|
||||
id: number;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
@@ -120,7 +122,24 @@ export default function EditorLayout() {
|
||||
const [newStackName, setNewStackName] = useState('');
|
||||
const [stackToDelete, setStackToDelete] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null);
|
||||
const [stackActions, setStackActions] = useState<Record<string, StackAction>>({});
|
||||
const stackActionsRef = useRef<Record<string, StackAction>>({});
|
||||
stackActionsRef.current = stackActions;
|
||||
|
||||
const setStackAction = (stackFile: string, action: StackAction) => {
|
||||
setStackActions(prev => ({ ...prev, [stackFile]: action }));
|
||||
};
|
||||
const clearStackAction = (stackFile: string) => {
|
||||
setStackActions(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[stackFile];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const isStackBusy = (stackFile: string) => stackFile in stackActions;
|
||||
|
||||
const loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null;
|
||||
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [isFileLoading, setIsFileLoading] = useState(false);
|
||||
const [backupInfo, setBackupInfo] = useState<{ exists: boolean; timestamp: number | null }>({ exists: false, timestamp: null });
|
||||
@@ -280,22 +299,36 @@ export default function EditorLayout() {
|
||||
const fileList: string[] = Array.isArray(data) ? data : [];
|
||||
setFiles(fileList);
|
||||
|
||||
// Fetch status for all stacks in parallel
|
||||
const statusResults = await Promise.allSettled(
|
||||
fileList.map(async (file) => {
|
||||
const containersRes = await apiFetch(`/stacks/${file}/containers`);
|
||||
const containers = await containersRes.json();
|
||||
const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running');
|
||||
return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) };
|
||||
})
|
||||
);
|
||||
const statuses: StackStatus = {};
|
||||
for (const result of statusResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
statuses[result.value.file] = result.value.status;
|
||||
// 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;
|
||||
if (statusRes.ok) {
|
||||
bulkStatuses = await statusRes.json();
|
||||
} else {
|
||||
// Fallback: query each stack individually (remote node may not have bulk endpoint)
|
||||
const statusResults = await Promise.allSettled(
|
||||
fileList.map(async (file) => {
|
||||
const containersRes = await apiFetch(`/stacks/${file}/containers`);
|
||||
const containers = await containersRes.json();
|
||||
const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running');
|
||||
return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) };
|
||||
})
|
||||
);
|
||||
bulkStatuses = {};
|
||||
for (const result of statusResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
bulkStatuses[result.value.file] = result.value.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
setStackStatuses(statuses);
|
||||
setStackStatuses(prev => {
|
||||
const next: StackStatus = {};
|
||||
for (const file of fileList) {
|
||||
const status = bulkStatuses?.[file] ?? 'unknown';
|
||||
next[file] = (file in stackActionsRef.current) ? (prev[file] ?? status) : status;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
refreshLabels();
|
||||
return fileList;
|
||||
} catch (error) {
|
||||
@@ -307,6 +340,10 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const setOptimisticStatus = (stackFile: string, status: 'running' | 'exited') => {
|
||||
setStackStatuses(prev => ({ ...prev, [stackFile]: status }));
|
||||
};
|
||||
|
||||
const refreshLabels = async () => {
|
||||
if (!isPro) return;
|
||||
try {
|
||||
@@ -846,28 +883,31 @@ export default function EditorLayout() {
|
||||
};
|
||||
|
||||
const rollbackStack = async () => {
|
||||
if (!selectedFile || loadingAction !== null) return;
|
||||
setLoadingAction('rollback');
|
||||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||||
const stackFile = selectedFile;
|
||||
setStackAction(stackFile, 'rollback');
|
||||
setOptimisticStatus(stackFile, 'running');
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${selectedFile}/rollback`, { method: 'POST' });
|
||||
const res = await apiFetch(`/stacks/${stackFile}/rollback`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err?.error || 'Rollback failed');
|
||||
}
|
||||
toast.success('Stack rolled back successfully.');
|
||||
// Reload the editor content
|
||||
const contentRes = await apiFetch(`/stacks/${selectedFile}`);
|
||||
const contentRes = await apiFetch(`/stacks/${stackFile}`);
|
||||
const text = await contentRes.text();
|
||||
setContent(text || '');
|
||||
setOriginalContent(text || '');
|
||||
// Refresh backup info
|
||||
const backupRes = await apiFetch(`/stacks/${selectedFile}/backup`);
|
||||
const backupRes = await apiFetch(`/stacks/${stackFile}/backup`);
|
||||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Rollback failed';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
clearStackAction(stackFile);
|
||||
refreshStacks(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -892,9 +932,11 @@ export default function EditorLayout() {
|
||||
const deployStack = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!selectedFile || loadingAction !== null) return;
|
||||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
setLoadingAction('deploy');
|
||||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||||
const stackFile = selectedFile;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
setStackAction(stackFile, 'deploy');
|
||||
setOptimisticStatus(stackFile, 'running');
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackName}/deploy`, {
|
||||
method: 'POST',
|
||||
@@ -905,10 +947,11 @@ export default function EditorLayout() {
|
||||
}
|
||||
toast.success("Stack deployed successfully!");
|
||||
// Refresh containers after deploy
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
if (selectedFile === stackFile) {
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
// Refresh backup info
|
||||
if (isPro) {
|
||||
try {
|
||||
@@ -921,16 +964,19 @@ export default function EditorLayout() {
|
||||
const msg = (error as Error).message || 'Failed to deploy stack';
|
||||
toast.error(isPro ? `${msg} - automatically rolled back to previous version.` : msg);
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
clearStackAction(stackFile);
|
||||
refreshStacks(true);
|
||||
}
|
||||
};
|
||||
|
||||
const stopStack = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!selectedFile || loadingAction !== null) return;
|
||||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
setLoadingAction('stop');
|
||||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||||
const stackFile = selectedFile;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
setStackAction(stackFile, 'stop');
|
||||
setOptimisticStatus(stackFile, 'exited');
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackName}/stop`, {
|
||||
method: 'POST',
|
||||
@@ -941,24 +987,28 @@ export default function EditorLayout() {
|
||||
}
|
||||
toast.success('Stack stopped successfully!');
|
||||
// Refresh containers after stop
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
if (selectedFile === stackFile) {
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to stop:', error);
|
||||
toast.error((error as Error).message || 'Failed to stop stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
clearStackAction(stackFile);
|
||||
refreshStacks(true);
|
||||
}
|
||||
};
|
||||
|
||||
const restartStack = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!selectedFile || loadingAction !== null) return;
|
||||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
setLoadingAction('restart');
|
||||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||||
const stackFile = selectedFile;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
setStackAction(stackFile, 'restart');
|
||||
setOptimisticStatus(stackFile, 'running');
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackName}/restart`, {
|
||||
method: 'POST',
|
||||
@@ -969,24 +1019,28 @@ export default function EditorLayout() {
|
||||
}
|
||||
toast.success('Stack restarted successfully!');
|
||||
// Refresh containers after restart
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
if (selectedFile === stackFile) {
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restart:', error);
|
||||
toast.error((error as Error).message || 'Failed to restart stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
clearStackAction(stackFile);
|
||||
refreshStacks(true);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStack = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!selectedFile || loadingAction !== null) return;
|
||||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
setLoadingAction('update');
|
||||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||||
const stackFile = selectedFile;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
setStackAction(stackFile, 'update');
|
||||
setOptimisticStatus(stackFile, 'running');
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackName}/update`, {
|
||||
method: 'POST',
|
||||
@@ -997,21 +1051,26 @@ export default function EditorLayout() {
|
||||
}
|
||||
toast.success('Stack updated successfully!');
|
||||
// Refresh containers after update
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
if (selectedFile === stackFile) {
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update:', error);
|
||||
toast.error((error as Error).message || 'Failed to update stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
clearStackAction(stackFile);
|
||||
refreshStacks(true);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteStack = async () => {
|
||||
if (!stackToDelete) return;
|
||||
setLoadingAction('delete');
|
||||
// Find matching file entry for per-stack tracking
|
||||
const deleteKey = files.find(f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete) ?? stackToDelete;
|
||||
if (isStackBusy(deleteKey)) return;
|
||||
setStackAction(deleteKey, 'delete');
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackToDelete}`, {
|
||||
method: 'DELETE',
|
||||
@@ -1038,15 +1097,23 @@ export default function EditorLayout() {
|
||||
console.error('Failed to delete stack:', error);
|
||||
toast.error((error as Error).message || 'Failed to delete stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
clearStackAction(deleteKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Context-menu-friendly stack actions (accept file name directly)
|
||||
const executeStackActionByFile = async (stackFile: string, action: string, endpoint: string) => {
|
||||
if (loadingAction !== null) return;
|
||||
const executeStackActionByFile = async (stackFile: string, action: StackAction, endpoint: string) => {
|
||||
if (isStackBusy(stackFile)) return;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
setLoadingAction(action);
|
||||
setStackAction(stackFile, action);
|
||||
|
||||
// Optimistic status update
|
||||
if (action === 'stop') {
|
||||
setOptimisticStatus(stackFile, 'exited');
|
||||
} else if (action === 'deploy' || action === 'restart' || action === 'update') {
|
||||
setOptimisticStatus(stackFile, 'running');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
@@ -1059,7 +1126,6 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
await refreshStacks(true);
|
||||
if (action === 'update') fetchImageUpdates();
|
||||
if (action === 'deploy' && isPro) {
|
||||
try {
|
||||
@@ -1072,7 +1138,8 @@ export default function EditorLayout() {
|
||||
const msg = (error as Error).message || `Failed to ${action} stack`;
|
||||
toast.error(action === 'deploy' && isPro ? `${msg} - automatically rolled back to previous version.` : msg);
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
clearStackAction(stackFile);
|
||||
refreshStacks(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1369,11 +1436,17 @@ export default function EditorLayout() {
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span
|
||||
className={`font-mono text-[10px] shrink-0 w-[18px] ${stackStatuses[file] === 'running' ? 'text-success' :
|
||||
className={`font-mono text-[10px] shrink-0 w-[18px] flex items-center ${
|
||||
isStackBusy(file) ? 'text-muted-foreground' :
|
||||
stackStatuses[file] === 'running' ? 'text-success' :
|
||||
stackStatuses[file] === 'exited' ? 'text-destructive' : 'text-stat-icon'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{stackStatuses[file] === 'running' ? 'UP' : stackStatuses[file] === 'exited' ? 'DN' : '--'}
|
||||
{isStackBusy(file)
|
||||
? <Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} />
|
||||
: stackStatuses[file] === 'running' ? 'UP'
|
||||
: stackStatuses[file] === 'exited' ? 'DN'
|
||||
: '--'}
|
||||
</span>
|
||||
<span className="flex-1 truncate font-mono text-[13px]">{getDisplayName(file)}</span>
|
||||
{isPro && stackLabelMap[file]?.length > 0 && (
|
||||
@@ -1421,19 +1494,19 @@ export default function EditorLayout() {
|
||||
Check for updates
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
||||
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Deploy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
||||
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
|
||||
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
Restart
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'update', 'update')}>
|
||||
<DropdownMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Update
|
||||
</DropdownMenuItem>
|
||||
@@ -1510,19 +1583,19 @@ export default function EditorLayout() {
|
||||
Check for updates
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
||||
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Deploy
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
||||
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
Stop
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
|
||||
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
Restart
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'update', 'update')}>
|
||||
<ContextMenuItem disabled={isStackBusy(file)} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Update
|
||||
</ContextMenuItem>
|
||||
@@ -1756,7 +1829,7 @@ export default function EditorLayout() {
|
||||
) : (
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={deployStack} disabled={loadingAction !== null}>
|
||||
<Play className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'deploy' || loadingAction === 'start' ? 'Starting...' : 'Start'}
|
||||
{loadingAction === 'deploy' ? 'Starting...' : 'Start'}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack} disabled={loadingAction !== null}>
|
||||
|
||||
Reference in New Issue
Block a user