Skip to content

Commit 07c005f

Browse files
feat: stop container
1 parent 3b8271f commit 07c005f

7 files changed

Lines changed: 203 additions & 12 deletions

File tree

src/backend/health-api.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,17 @@ import {
66
type TimeStep,
77
type TimeRange,
88
} from "./utils/container-health.ts";
9-
import { restartContainer, getRestartCount } from "./utils/auto-restart.ts";
9+
import { restartContainer, stopContainer, getRestartCount, getStopCount } from "./utils/auto-restart.ts";
1010
import { getMonitorStatus, triggerHealthCheck } from "./health-monitor.ts";
1111
import { checkJWT } from "./utils/jwt.ts";
1212

1313
const TIME_RANGE_PRESETS: Record<TimeStep, TimeRange> = {
14-
'1s': { step: '1s', duration: '5m' },
15-
'15s': { step: '15s', duration: '15m' },
16-
'1m': { step: '1m', duration: '1h' },
17-
'5m': { step: '5m', duration: '6h' },
18-
'1h': { step: '1h', duration: '24h' },
19-
'1d': { step: '1d', duration: '7d' },
14+
'1s': { step: '1s', duration: '5m' },
15+
'15s': { step: '15s', duration: '15m' },
16+
'1m': { step: '1m', duration: '1h' },
17+
'5m': { step: '5m', duration: '6h' },
18+
'1h': { step: '1h', duration: '24h' },
19+
'1d': { step: '1d', duration: '7d' },
2020
};
2121

2222

@@ -44,6 +44,7 @@ export async function getContainerHealth(ctx: Context): Promise<void> {
4444
memoryPercent: Math.round(c.memoryPercent * 100) / 100,
4545
memoryUsageMB: Math.round(c.memoryUsage / (1024 * 1024)),
4646
restartCount: getRestartCount(c.name),
47+
stopCount: getStopCount(c.name),
4748
isHealthy: !isUnhealthy(c),
4849
lastUpdated: c.lastUpdated.toISOString(),
4950
})),
@@ -160,6 +161,42 @@ export async function restartContainerHandler(ctx: Context): Promise<void> {
160161
}
161162
}
162163

164+
export async function stopContainerHandler(ctx: Context): Promise<void> {
165+
const subdomain = ctx.params.subdomain;
166+
167+
const body = await ctx.request.body().value;
168+
let document;
169+
try {
170+
document = typeof body === 'string' ? JSON.parse(body) : body;
171+
} catch {
172+
document = body;
173+
}
174+
175+
const author = document?.author;
176+
const token = document?.token;
177+
const provider = document?.provider;
178+
179+
if (author !== await checkJWT(provider, token)) {
180+
ctx.throw(401);
181+
}
182+
183+
try {
184+
await stopContainer(subdomain);
185+
186+
ctx.response.headers.set("Access-Control-Allow-Origin", "*");
187+
ctx.response.body = {
188+
status: "success",
189+
message: `Container ${subdomain} stop initiated`,
190+
};
191+
} catch (error) {
192+
ctx.response.status = 500;
193+
ctx.response.body = {
194+
status: "error",
195+
message: `Failed to stop ${subdomain}: ${error}`,
196+
};
197+
}
198+
}
199+
163200

164201
export async function triggerHealthCheckHandler(ctx: Context): Promise<void> {
165202
const body = await ctx.request.body().value;

src/backend/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
getContainerMetrics,
2020
getHealthDashboard,
2121
restartContainerHandler,
22+
stopContainerHandler,
2223
triggerHealthCheckHandler,
2324
} from "./health-api.ts";
2425
import { startHealthMonitor } from "./health-monitor.ts";
@@ -76,6 +77,7 @@ router
7677
.get("/health/summary", (ctx) => getHealthDashboard(ctx))
7778
.get("/health/:subdomain/metrics", (ctx) => getContainerMetrics(ctx))
7879
.post("/health/:subdomain/restart", (ctx) => restartContainerHandler(ctx))
80+
.post("/health/:subdomain/stop", (ctx) => stopContainerHandler(ctx))
7981
.post("/health/check", (ctx) => triggerHealthCheckHandler(ctx));
8082

8183
app.use(oakCors());

src/backend/shell_scripts/restart.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ arg1=$1
1313
echo "Restarting... $arg1"
1414

1515
sudo docker restart $arg1
16+
17+
# Re-enable nginx routing if it was previously stopped
18+
if [ ! -f "/etc/nginx/sites-enabled/$arg1.conf" ] && [ -f "/etc/nginx/sites-available/$arg1.conf" ]; then
19+
sudo ln -s /etc/nginx/sites-available/$arg1.conf /etc/nginx/sites-enabled/$arg1.conf
20+
sudo systemctl reload nginx
21+
fi

src/backend/shell_scripts/stop.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/bash
2+
3+
# This script takes in 1 command line argument (the container name)
4+
5+
id -u
6+
if [ "$#" -ne 1 ]; then
7+
echo "Usage: $0 container_name"
8+
exit 1
9+
fi
10+
11+
arg1=$1
12+
13+
echo "Stopping... $arg1"
14+
15+
sudo docker stop $arg1
16+
17+
# Disable nginx routing for this domain so it doesn't return 502
18+
if [ -f "/etc/nginx/sites-enabled/$arg1.conf" ]; then
19+
sudo rm /etc/nginx/sites-enabled/$arg1.conf
20+
sudo systemctl reload nginx
21+
fi

src/backend/tests/container-health.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,74 @@ Deno.test("getRestartCount - tracks multiple containers separately", () => {
656656
assertEquals(getRestartCount("container-b"), 5);
657657
});
658658

659+
Deno.test("getStopCount - returns correct count", () => {
660+
const stopCounts = new Map<string, { count: number; lastStop: Date }>();
661+
stopCounts.set("test-container", { count: 3, lastStop: new Date() });
662+
663+
const getStopCount = (containerName: string): number => {
664+
return stopCounts.get(containerName)?.count || 0;
665+
};
666+
667+
assertEquals(getStopCount("test-container"), 3);
668+
});
669+
670+
Deno.test("restartContainer - updates restart count", async () => {
671+
const restartCounts = new Map<string, { count: number; lastRestart: Date }>();
672+
restartCounts.set("test-container", { count: 1, lastRestart: new Date(Date.now() - 10000) });
673+
674+
let execCalledWith = "";
675+
const mockExec = async (cmd: string) => {
676+
execCalledWith = cmd;
677+
};
678+
679+
const restartContainer = async (containerName: string): Promise<void> => {
680+
await mockExec(`bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh ${containerName}' > /hostpipe/pipe"`);
681+
const current = restartCounts.get(containerName);
682+
restartCounts.set(containerName, {
683+
count: (current?.count || 0) + 1,
684+
lastRestart: new Date(),
685+
});
686+
};
687+
688+
const before = new Date();
689+
await restartContainer("test-container");
690+
const after = new Date();
691+
692+
assertEquals(execCalledWith, `bash -c "echo 'bash ../../src/backend/shell_scripts/restart.sh test-container' > /hostpipe/pipe"`);
693+
694+
const stats = restartCounts.get("test-container");
695+
assertExists(stats);
696+
assertEquals(stats.count, 2);
697+
assert(stats.lastRestart.getTime() >= before.getTime() && stats.lastRestart.getTime() <= after.getTime());
698+
});
699+
700+
Deno.test("stopContainer - updates stop count and calls stop script", async () => {
701+
const stopCounts = new Map<string, { count: number; lastStop: Date }>();
702+
let execCalledWith = "";
703+
const mockExec = async (cmd: string) => {
704+
execCalledWith = cmd;
705+
};
706+
707+
const stopContainer = async (containerName: string): Promise<void> => {
708+
await mockExec(`bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh ${containerName}' > /hostpipe/pipe"`);
709+
const current = stopCounts.get(containerName);
710+
stopCounts.set(containerName, {
711+
count: (current?.count || 0) + 1,
712+
lastStop: new Date(),
713+
});
714+
};
715+
716+
const before = new Date();
717+
await stopContainer("test-container");
718+
const after = new Date();
719+
720+
assertEquals(execCalledWith, `bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh test-container' > /hostpipe/pipe"`);
721+
const stats = stopCounts.get("test-container");
722+
assertExists(stats);
723+
assertEquals(stats.count, 1);
724+
assert(stats.lastStop.getTime() >= before.getTime() && stats.lastStop.getTime() <= after.getTime());
725+
});
726+
659727
Deno.test("ContainerStats - validates all required fields", () => {
660728
const validStats: ContainerStats = {
661729
containerId: "abc123",

src/backend/utils/auto-restart.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { exec } from "../dependencies.ts";
22

33
const restartCounts = new Map<string, { count: number; lastRestart: Date }>();
4+
const stopCounts = new Map<string, { count: number; lastStop: Date }>();
45

56
export function getRestartCount(containerName: string): number {
67
return restartCounts.get(containerName)?.count || 0;
78
}
89

10+
export function getStopCount(containerName: string): number {
11+
return stopCounts.get(containerName)?.count || 0;
12+
}
13+
914
export async function restartContainer(containerName: string): Promise<void> {
1015
try {
1116
await exec(
@@ -23,3 +28,20 @@ export async function restartContainer(containerName: string): Promise<void> {
2328
throw error;
2429
}
2530
}
31+
32+
export async function stopContainer(containerName: string): Promise<void> {
33+
try {
34+
await exec(
35+
`bash -c "echo 'bash ../../src/backend/shell_scripts/stop.sh ${containerName}' > /hostpipe/pipe"`
36+
);
37+
38+
const current = stopCounts.get(containerName);
39+
stopCounts.set(containerName, {
40+
count: (current?.count || 0) + 1,
41+
lastStop: new Date(),
42+
});
43+
} catch (error) {
44+
console.error(`Failed to stop ${containerName}:`, error);
45+
throw error;
46+
}
47+
}

src/frontend/src/components/ContainerHealth.vue

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,17 @@
7777
</div>
7878

7979
<div class="container-stats">
80-
<span><strong>Restarts:</strong> {{ container.restartCount }}</span>
80+
<span><strong>Restarts:</strong> {{ container.restartCount }}</span> |
81+
<span><strong>Stops:</strong> {{ container.stopCount }}</span>
8182
</div>
8283

8384
<div class="container-actions">
8485
<button @click="viewMetrics(container)" class="view-btn">
8586
View Metrics
8687
</button>
88+
<button @click="stopContainer(container)" class="stop-btn">
89+
Stop
90+
</button>
8791
<button @click="restartContainer(container)" class="restart-btn">
8892
Restart
8993
</button>
@@ -140,6 +144,7 @@ interface Container {
140144
memoryPercent: number;
141145
memoryUsageMB: number;
142146
restartCount: number;
147+
stopCount: number;
143148
isHealthy: boolean;
144149
lastUpdated: string;
145150
}
@@ -342,26 +347,49 @@ export default defineComponent({
342347
};
343348
344349
const restartContainer = async (container: Container) => {
345-
if (!confirm(`Restart container ${container.subdomain}?`)) return;
350+
const containerIdentifier = container.subdomain || container.name;
351+
if (!confirm(`Restart container ${containerIdentifier}?`)) return;
346352
347353
try {
348354
const token = localStorage.getItem('JWTUser') || '';
349355
const provider = localStorage.getItem('provider') || '';
350356
351-
await fetch(`${BACKEND_URL}/health/${container.subdomain}/restart`, {
357+
await fetch(`${BACKEND_URL}/health/${containerIdentifier}/restart`, {
352358
method: 'POST',
353359
headers: { 'Content-Type': 'application/json' },
354360
body: JSON.stringify({ author: username.value, token: token, provider }),
355361
});
356362
357-
alert(`Restart initiated for ${container.subdomain}`);
363+
alert(`Restart initiated for ${containerIdentifier}`);
358364
fetchHealth();
359365
} catch (error) {
360366
console.error('Failed to restart:', error);
361367
alert('Failed to restart container');
362368
}
363369
};
364370
371+
const stopContainer = async (container: Container) => {
372+
const containerIdentifier = container.subdomain || container.name;
373+
if (!confirm(`Stop container ${containerIdentifier}?`)) return;
374+
375+
try {
376+
const token = localStorage.getItem('JWTUser') || '';
377+
const provider = localStorage.getItem('provider') || '';
378+
379+
await fetch(`${BACKEND_URL}/health/${containerIdentifier}/stop`, {
380+
method: 'POST',
381+
headers: { 'Content-Type': 'application/json' },
382+
body: JSON.stringify({ author: username.value, token: token, provider }),
383+
});
384+
385+
alert(`Stop initiated for ${containerIdentifier}`);
386+
fetchHealth();
387+
} catch (error) {
388+
console.error('Failed to stop:', error);
389+
alert('Failed to stop container');
390+
}
391+
};
392+
365393
onMounted(async() => {
366394
await fetchUsername();
367395
fetchHealth();
@@ -382,6 +410,7 @@ export default defineComponent({
382410
closeMetrics,
383411
loadMetrics,
384412
restartContainer,
413+
stopContainer,
385414
};
386415
},
387416
});
@@ -570,7 +599,7 @@ export default defineComponent({
570599
gap: 0.75rem;
571600
}
572601
573-
.view-btn, .restart-btn {
602+
.view-btn, .restart-btn, .stop-btn {
574603
flex: 1;
575604
padding: 0.5rem;
576605
border-radius: 6px;
@@ -585,6 +614,12 @@ export default defineComponent({
585614
border: 1px solid #bfdbfe;
586615
}
587616
617+
.stop-btn {
618+
background: #fee2e2;
619+
color: #991b1b;
620+
border: 1px solid #fecaca;
621+
}
622+
588623
.restart-btn {
589624
background: #fef3c7;
590625
color: #92400e;

0 commit comments

Comments
 (0)