mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(stacks): per-service start/stop/restart lifecycle actions (#778)
* feat(stacks): add per-service start/stop/restart lifecycle routes
Adds POST /:stackName/services/:serviceName/{start,stop,restart} routes
that operate on containers belonging to a single Compose service, using
the same Engine API pattern as the existing stack-level lifecycle routes.
Includes isValidServiceName validator and audit-summary entries for the
three new paths.
* test(stacks): add per-service action route tests
* test(stacks): fix test quality issues in service action tests
* feat(stacks): add per-service lifecycle menu to container cards
* fix(stacks): handle paused container state in service action menu
* docs(stacks): add per-service lifecycle actions documentation
* docs(stacks): add validation screenshots for per-service lifecycle actions
This commit is contained in:
@@ -65,6 +65,18 @@ describe('getAuditSummary()', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/mystack/rollback')).toBe('Rolled back stack: mystack');
|
||||
});
|
||||
|
||||
it('resolves per-service restart summary (stack name as resource)', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/web/services/app/restart')).toBe('Restarted stack service: web');
|
||||
});
|
||||
|
||||
it('resolves per-service stop summary (stack name as resource)', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/web/services/app/stop')).toBe('Stopped stack service: web');
|
||||
});
|
||||
|
||||
it('resolves per-service start summary (stack name as resource)', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/web/services/app/start')).toBe('Started stack service: web');
|
||||
});
|
||||
|
||||
it('decodes URL-encoded resource names', () => {
|
||||
expect(getAuditSummary('POST', '/stacks/my%20stack/deploy')).toBe('Deployed stack: my stack');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Integration tests for per-service lifecycle routes:
|
||||
* POST /api/stacks/:stackName/services/:serviceName/restart
|
||||
* POST /api/stacks/:stackName/services/:serviceName/stop
|
||||
* POST /api/stacks/:stackName/services/:serviceName/start
|
||||
*
|
||||
* Verifies permission gating, name validation, container filtering, fan-out
|
||||
* to the correct DockerController method, 404 paths, and error propagation.
|
||||
*
|
||||
* DockerController is mocked at the service layer so no real Docker daemon
|
||||
* is required. All other external dependencies are stubbed in kind.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
// ── Hoisted mocks (must come before importing the app) ──────────────────────
|
||||
|
||||
const {
|
||||
mockGetContainersByStack,
|
||||
mockRestartContainer,
|
||||
mockStopContainer,
|
||||
mockStartContainer,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetContainersByStack: vi.fn(),
|
||||
mockRestartContainer: vi.fn(),
|
||||
mockStopContainer: vi.fn(),
|
||||
mockStartContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DockerController', async () => {
|
||||
const actual = await vi.importActual<typeof import('../services/DockerController')>(
|
||||
'../services/DockerController',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual.default,
|
||||
getInstance: () => ({
|
||||
getContainersByStack: mockGetContainersByStack,
|
||||
restartContainer: mockRestartContainer,
|
||||
stopContainer: mockStopContainer,
|
||||
startContainer: mockStartContainer,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/FileSystemService', () => ({
|
||||
FileSystemService: {
|
||||
getInstance: () => ({
|
||||
getStacks: vi.fn().mockResolvedValue([]),
|
||||
getBaseDir: () => '/tmp/compose',
|
||||
readComposeFile: vi.fn().mockResolvedValue(''),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Container fixture helpers ───────────────────────────────────────────────
|
||||
|
||||
interface ContainerFixture {
|
||||
Id: string;
|
||||
Service: string;
|
||||
Names: string[];
|
||||
State: string;
|
||||
Status: string;
|
||||
Ports: { PrivatePort: number; PublicPort: number }[];
|
||||
}
|
||||
|
||||
function makeContainer(id: string, service: string): ContainerFixture {
|
||||
return {
|
||||
Id: id,
|
||||
Service: service,
|
||||
Names: [`/${service}`],
|
||||
State: 'running',
|
||||
Status: 'Up 1 second',
|
||||
Ports: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authCookie: string;
|
||||
let viewerCookie: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'svc-viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'svc-viewer', password: 'viewerpass' });
|
||||
const cookies = viewerRes.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetContainersByStack.mockReset();
|
||||
mockRestartContainer.mockReset();
|
||||
mockStopContainer.mockReset();
|
||||
mockStartContainer.mockReset();
|
||||
|
||||
// Default: no containers found (safe baseline for tests that set their own value)
|
||||
mockGetContainersByStack.mockResolvedValue([]);
|
||||
|
||||
// Default: operations resolve successfully
|
||||
mockRestartContainer.mockResolvedValue(undefined);
|
||||
mockStopContainer.mockResolvedValue(undefined);
|
||||
mockStartContainer.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/stacks/:stackName/services/:serviceName/restart', () => {
|
||||
it('happy path: restarts matched container, ignores other services', async () => {
|
||||
const appContainer = makeContainer('container-app-1', 'app');
|
||||
const dbContainer = makeContainer('container-db-1', 'db');
|
||||
mockGetContainersByStack.mockResolvedValue([appContainer, dbContainer]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/services/app/restart')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.count).toBe(1);
|
||||
|
||||
expect(mockRestartContainer).toHaveBeenCalledTimes(1);
|
||||
expect(mockRestartContainer).toHaveBeenCalledWith('container-app-1');
|
||||
expect(mockRestartContainer).not.toHaveBeenCalledWith('container-db-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/:stackName/services/:serviceName/stop', () => {
|
||||
it('happy path: stops matched container only', async () => {
|
||||
const appContainer = makeContainer('container-app-1', 'app');
|
||||
const dbContainer = makeContainer('container-db-1', 'db');
|
||||
mockGetContainersByStack.mockResolvedValue([appContainer, dbContainer]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/services/app/stop')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.count).toBe(1);
|
||||
|
||||
expect(mockStopContainer).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopContainer).toHaveBeenCalledWith('container-app-1');
|
||||
expect(mockStopContainer).not.toHaveBeenCalledWith('container-db-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/:stackName/services/:serviceName/start', () => {
|
||||
it('happy path: starts matched container only', async () => {
|
||||
const appContainer = makeContainer('container-app-1', 'app');
|
||||
const dbContainer = makeContainer('container-db-1', 'db');
|
||||
mockGetContainersByStack.mockResolvedValue([appContainer, dbContainer]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/services/app/start')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.count).toBe(1);
|
||||
|
||||
expect(mockStartContainer).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartContainer).toHaveBeenCalledWith('container-app-1');
|
||||
expect(mockStartContainer).not.toHaveBeenCalledWith('container-db-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-replica fan-out', () => {
|
||||
it('restarts all replicas when multiple containers share the same service name', async () => {
|
||||
const containers = [
|
||||
makeContainer('container-app-1', 'app'),
|
||||
makeContainer('container-app-2', 'app'),
|
||||
makeContainer('container-app-3', 'app'),
|
||||
];
|
||||
mockGetContainersByStack.mockResolvedValue(containers);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/services/app/restart')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(3);
|
||||
expect(mockRestartContainer).toHaveBeenCalledTimes(3);
|
||||
expect(mockRestartContainer).toHaveBeenCalledWith('container-app-1');
|
||||
expect(mockRestartContainer).toHaveBeenCalledWith('container-app-2');
|
||||
expect(mockRestartContainer).toHaveBeenCalledWith('container-app-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('404 error cases', () => {
|
||||
it('returns 404 when requested service is not in the stack', async () => {
|
||||
mockGetContainersByStack.mockResolvedValue([
|
||||
makeContainer('container-app-1', 'app'),
|
||||
makeContainer('container-db-1', 'db'),
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/services/nginx/restart')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("Service 'nginx' not found in stack 'web'.");
|
||||
expect(mockRestartContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when stack has no containers', async () => {
|
||||
mockGetContainersByStack.mockResolvedValue([]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/services/app/restart')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe('No containers found for this stack.');
|
||||
expect(mockRestartContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('400 validation errors', () => {
|
||||
it('returns 400 for stack name containing invalid characters', async () => {
|
||||
// Express decodes %2F but a literal ".." fails isValidStackName
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/..invalid../services/app/restart')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid stack name');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid service name (starts with hyphen)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/services/-invalid/restart')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid service name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication', () => {
|
||||
it('returns 401 when request has no auth cookie', async () => {
|
||||
const res = await request(app).post('/api/stacks/web/services/app/restart');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for viewer role (no write permission)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/services/app/restart')
|
||||
.set('Cookie', viewerCookie);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockRestartContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker error propagation', () => {
|
||||
it('returns 500 with the error message when restartContainer rejects', async () => {
|
||||
mockGetContainersByStack.mockResolvedValue([makeContainer('container-app-1', 'app')]);
|
||||
mockRestartContainer.mockRejectedValue(new Error('daemon error'));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/services/app/restart')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toContain('daemon error');
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { requirePaid, requireAdmin } from '../middleware/tierGates';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { isValidStackName, isPathWithinBase } from '../utils/validation';
|
||||
import { isValidStackName, isValidServiceName, isPathWithinBase } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sendGitSourceError } from '../utils/gitSourceHttp';
|
||||
@@ -692,6 +692,65 @@ stacksRouter.post('/:stackName/start', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
type ServiceAction = 'start' | 'stop' | 'restart';
|
||||
|
||||
async function handleServiceAction(
|
||||
req: Request,
|
||||
res: Response,
|
||||
action: ServiceAction,
|
||||
): Promise<void> {
|
||||
const stackName = req.params.stackName as string;
|
||||
const serviceName = req.params.serviceName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!isValidServiceName(serviceName)) {
|
||||
res.status(400).json({ error: 'Invalid service name' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const all = await dockerController.getContainersByStack(stackName);
|
||||
if (!all || all.length === 0) {
|
||||
res.status(404).json({ error: 'No containers found for this stack.' });
|
||||
return;
|
||||
}
|
||||
const matching = all.filter(c => c.Service === serviceName);
|
||||
if (matching.length === 0) {
|
||||
res.status(404).json({ error: `Service '${serviceName}' not found in stack '${stackName}'.` });
|
||||
return;
|
||||
}
|
||||
const op =
|
||||
action === 'start'
|
||||
? (id: string) => dockerController.startContainer(id)
|
||||
: action === 'stop'
|
||||
? (id: string) => dockerController.stopContainer(id)
|
||||
: (id: string) => dockerController.restartContainer(id);
|
||||
await Promise.all(matching.map(c => op(c.Id)));
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(
|
||||
`[Stacks] Service ${action} completed: ${stackName}/${serviceName} (${matching.length} containers)`,
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Service ${action} completed via Engine API.`,
|
||||
count: matching.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Service ${action} failed: ${stackName}/${serviceName}`, error);
|
||||
res.status(500).json({ error: getErrorMessage(error, `Failed to ${action} service`) });
|
||||
}
|
||||
}
|
||||
|
||||
stacksRouter.post('/:stackName/services/:serviceName/restart', (req, res) =>
|
||||
handleServiceAction(req, res, 'restart'));
|
||||
stacksRouter.post('/:stackName/services/:serviceName/stop', (req, res) =>
|
||||
handleServiceAction(req, res, 'stop'));
|
||||
stacksRouter.post('/:stackName/services/:serviceName/start', (req, res) =>
|
||||
handleServiceAction(req, res, 'start'));
|
||||
|
||||
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
|
||||
@@ -18,6 +18,12 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'POST /stacks/*/start': 'Started stack',
|
||||
'POST /stacks/*/stop': 'Stopped stack',
|
||||
'POST /stacks/*/restart': 'Restarted stack',
|
||||
|
||||
// Per-service lifecycle (resourceName = stack name; service name is in the path column)
|
||||
'POST /stacks/*/services/*/start': 'Started stack service',
|
||||
'POST /stacks/*/services/*/stop': 'Stopped stack service',
|
||||
'POST /stacks/*/services/*/restart': 'Restarted stack service',
|
||||
|
||||
'POST /stacks/*/update': 'Updated stack images',
|
||||
'POST /stacks/*/rollback': 'Rolled back stack',
|
||||
|
||||
|
||||
@@ -72,6 +72,13 @@ export function isValidDockerResourceId(id: string): boolean {
|
||||
return /^[a-f0-9]{12,64}$/i.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose service name. Allows dots in addition to the stack-name set
|
||||
* (Compose spec permits `my.service`).
|
||||
*/
|
||||
export const isValidServiceName = (name: string): boolean =>
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name);
|
||||
|
||||
/**
|
||||
* Asserts that a resolved file path stays within a given base directory.
|
||||
* Returns true if the path is safe, false if it escapes the base.
|
||||
|
||||
@@ -213,6 +213,20 @@ The stack header groups actions by frequency of use. The most common action is t
|
||||
Stack directories often contain root-owned files, either because Docker Compose creates them, or because the stacks were originally set up outside of Sencho with `sudo`. Sencho handles this automatically: if a normal deletion fails due to permissions, it uses Docker to clean up root-owned files. No manual intervention is needed in the standard setup.
|
||||
</Note>
|
||||
|
||||
## Controlling a single service
|
||||
|
||||
Inside the stack detail view, each service row has a kebab menu (`⋮`) on the right side. Use it to act on that service without touching the rest of the stack.
|
||||
|
||||
- **Restart service** — stops and starts all containers for that service.
|
||||
- **Stop service** — stops all containers for that service. Only shown when the service is running.
|
||||
- **Start service** — starts all containers for that service. Only shown when the service is stopped or exited.
|
||||
|
||||
For services running multiple replicas, the action applies to every replica simultaneously. Sibling services are not affected.
|
||||
|
||||
<Note>
|
||||
The service name in the action must match the `services:` key in your `compose.yaml`. If no containers exist yet for that service, the action will fail with a "service not found" error. Make sure the service is defined in the compose file and has been deployed at least once.
|
||||
</Note>
|
||||
|
||||
## Stack context menu
|
||||
|
||||
Right-click any stack in the sidebar to open the context menu. The menu adapts to the stack's current state; only relevant actions are shown.
|
||||
@@ -260,3 +274,9 @@ Click the **folder-search icon** next to the "Create Stack" button in the sideba
|
||||
<Tip>
|
||||
Any subdirectory inside your `COMPOSE_DIR` that contains a `compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml` file is recognized as a valid stack.
|
||||
</Tip>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service action returns a "service not found" error
|
||||
|
||||
The service name used in the action must match the `services:` key in the stack's `compose.yaml`. This error occurs when no running containers match that service name, either because the service was never deployed or because the compose file defines a different name. Verify the service key in your compose file and ensure the stack has been deployed at least once so containers exist for that service.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
@@ -76,6 +76,7 @@ import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
|
||||
interface ContainerInfo {
|
||||
Id: string;
|
||||
Names: string[];
|
||||
Service?: string;
|
||||
State: string;
|
||||
Status?: string;
|
||||
Ports?: { PrivatePort: number, PublicPort: number }[];
|
||||
@@ -1471,6 +1472,27 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const serviceAction = async (action: 'start' | 'stop' | 'restart', serviceName: string) => {
|
||||
if (!selectedFile) return;
|
||||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
try {
|
||||
const r = await apiFetch(`/stacks/${stackName}/services/${encodeURIComponent(serviceName)}/${action}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.text()) || `${action} failed`);
|
||||
const label = action === 'restart' ? 'restarted' : action === 'stop' ? 'stopped' : 'started';
|
||||
toast.success(`Service "${serviceName}" ${label}`);
|
||||
const cr = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await cr.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
} catch (e) {
|
||||
console.error(`Failed to ${action} service "${serviceName}":`, e);
|
||||
toast.error((e as Error).message || `Failed to ${action} service "${serviceName}"`);
|
||||
} finally {
|
||||
refreshStacks(true);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStack = async (e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
@@ -2483,19 +2505,19 @@ export default function EditorLayout() {
|
||||
}
|
||||
|
||||
const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container';
|
||||
const isRunning = container.State === 'running';
|
||||
const isActive = container.State === 'running' || container.State === 'paused';
|
||||
const health = container.healthStatus;
|
||||
const uptime = isRunning ? extractUptime(container.Status) : null;
|
||||
const uptime = isActive ? extractUptime(container.Status) : null;
|
||||
const hcLabel = healthcheckLabel(health);
|
||||
const stats = containerStats[container?.Id];
|
||||
const history = stats?.history;
|
||||
|
||||
const badgeClass = health === 'unhealthy' || !isRunning
|
||||
const badgeClass = health === 'unhealthy' || !isActive
|
||||
? 'bg-destructive text-destructive-foreground'
|
||||
: health === 'starting'
|
||||
? 'bg-warning text-warning-foreground'
|
||||
: 'bg-success text-success-foreground';
|
||||
const badgeGlyph = health === 'unhealthy' || !isRunning ? '✗' : health === 'starting' ? '…' : '✓';
|
||||
const badgeGlyph = health === 'unhealthy' || !isActive ? '✗' : health === 'starting' ? '…' : '✓';
|
||||
const sparkStroke = health === 'unhealthy' ? 'var(--destructive)' : health === 'starting' ? 'var(--warning)' : 'var(--chart-1)';
|
||||
|
||||
return (
|
||||
@@ -2537,7 +2559,7 @@ export default function EditorLayout() {
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md"
|
||||
onClick={() => openLogViewer(container?.Id, containerName)}
|
||||
disabled={!isRunning}
|
||||
disabled={!isActive}
|
||||
aria-label="View logs"
|
||||
>
|
||||
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
@@ -2548,15 +2570,45 @@ export default function EditorLayout() {
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md"
|
||||
onClick={() => openBashModal(container?.Id, containerName)}
|
||||
disabled={!isRunning}
|
||||
disabled={!isActive}
|
||||
aria-label="Open bash shell"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
{container.Service && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md"
|
||||
aria-label="Service actions"
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isActive ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('restart', container.Service!)}>
|
||||
Restart service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('stop', container.Service!)}>
|
||||
Stop service
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => serviceAction('start', container.Service!)}>
|
||||
Start service
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isRunning ? (
|
||||
{isActive ? (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
|
||||
Reference in New Issue
Block a user