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