Skip to content

Commit aa8aae1

Browse files
committed
#22
1 parent 11f8824 commit aa8aae1

9 files changed

Lines changed: 316 additions & 28 deletions

File tree

Dockerfile

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,16 @@ ENV NODE_ENV=production
4747
ENV NEXT_TELEMETRY_DISABLED=1
4848
ENV PORT=3000
4949

50-
# Create non-root user (but still needs access to docker.sock)
51-
RUN addgroup --system --gid 1001 nodejs \
52-
&& adduser --system --uid 1001 nextjs
53-
5450
# Copy build artifacts
5551
COPY --from=builder /app/public ./public
56-
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
57-
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
58-
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
52+
COPY --from=builder /app/.next/standalone ./
53+
COPY --from=builder /app/.next/static ./.next/static
54+
COPY --from=builder /app/node_modules ./node_modules
5955
COPY --from=builder /app/prisma ./prisma
6056
COPY --from=builder /app/server.mjs ./server.mjs
6157

6258
# Data directory for SQLite
63-
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
59+
RUN mkdir -p /app/data
6460

6561
# Host filesystem mount point
6662
RUN mkdir -p /host_system
@@ -69,7 +65,7 @@ RUN mkdir -p /host_system
6965
COPY docker-entrypoint.sh /docker-entrypoint.sh
7066
RUN chmod +x /docker-entrypoint.sh
7167

72-
USER nextjs
68+
USER root
7369

7470
EXPOSE 3000
7571

server.mjs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,10 @@ app.prepare().then(() => {
148148
const cwd = process.env.HOST_FS_MOUNT ?? "/host_system";
149149

150150
const ptyProcess =
151-
mode === "container"
152-
? pty.spawn(
153-
"docker",
154-
["exec", "-it", containerId, "/bin/sh"],
151+
mode === "container"
152+
? pty.spawn(
153+
"docker",
154+
["exec", "-u", "0", "-it", containerId, "/bin/sh"],
155155
{
156156
name: "xterm-256color",
157157
cols: 80,

src/app/(dashboard)/audit/page.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { redirect } from "next/navigation";
2+
import { getCurrentUser } from "@/lib/auth";
3+
import { db } from "@/lib/db";
4+
5+
export const dynamic = "force-dynamic";
6+
7+
export default async function AuditPage() {
8+
const user = await getCurrentUser();
9+
if (!user) redirect("/login");
10+
if (user.role !== "ADMIN") redirect("/dashboard");
11+
12+
const logs = await db.auditLog.findMany({
13+
orderBy: { createdAt: "desc" },
14+
take: 200,
15+
});
16+
17+
return (
18+
<div className="space-y-6">
19+
<div>
20+
<h1 className="text-2xl font-bold tracking-tight">Audit Log</h1>
21+
<p className="text-muted-foreground text-sm mt-1">Recent security-relevant actions in the system</p>
22+
</div>
23+
24+
{logs.length === 0 ? (
25+
<div className="rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground">
26+
No audit events yet.
27+
</div>
28+
) : (
29+
<div className="overflow-x-auto rounded-xl border border-border bg-card">
30+
<table className="w-full text-sm">
31+
<thead className="border-b border-border bg-muted/40 text-muted-foreground">
32+
<tr>
33+
<th className="px-4 py-3 text-left font-medium">Time</th>
34+
<th className="px-4 py-3 text-left font-medium">User</th>
35+
<th className="px-4 py-3 text-left font-medium">Action</th>
36+
<th className="px-4 py-3 text-left font-medium">Resource</th>
37+
<th className="px-4 py-3 text-left font-medium">Result</th>
38+
<th className="px-4 py-3 text-left font-medium">IP</th>
39+
<th className="px-4 py-3 text-left font-medium">Detail</th>
40+
</tr>
41+
</thead>
42+
<tbody>
43+
{logs.map((log) => (
44+
<tr key={log.id} className="border-b border-border/60 last:border-b-0">
45+
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{new Date(log.createdAt).toLocaleString()}</td>
46+
<td className="px-4 py-3 font-medium">{log.username}</td>
47+
<td className="px-4 py-3 font-mono text-xs">{log.action}</td>
48+
<td className="px-4 py-3 font-mono text-xs">{log.resource}</td>
49+
<td className="px-4 py-3">
50+
<span
51+
className={`inline-flex rounded-full px-2 py-0.5 text-xs ${
52+
log.success
53+
? "bg-emerald-500/10 text-emerald-300 border border-emerald-500/30"
54+
: "bg-destructive/10 text-destructive border border-destructive/30"
55+
}`}
56+
>
57+
{log.success ? "success" : "failed"}
58+
</span>
59+
</td>
60+
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{log.ipAddress ?? "-"}</td>
61+
<td className="px-4 py-3 text-muted-foreground">{log.detail ?? "-"}</td>
62+
</tr>
63+
))}
64+
</tbody>
65+
</table>
66+
</div>
67+
)}
68+
</div>
69+
);
70+
}

src/app/(dashboard)/containers/[id]/page.tsx

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "@/lib/rbac";
1515
import Link from "next/link";
1616
import { ContainerDetailsActions } from "@/components/docker/ContainerDetailsActions";
17+
import { ContainerEditPanel } from "@/components/docker/ContainerEditPanel";
1718

1819
type Params = { params: Promise<{ id: string }> };
1920

@@ -29,20 +30,32 @@ export default async function ContainerInspectPage({ params }: Params) {
2930
redirect("/containers");
3031
}
3132

32-
let inspect: unknown = null;
33+
let inspect: Record<string, unknown> | null = null;
3334
let error: string | null = null;
3435

3536
try {
36-
inspect = await getContainerInspect(id);
37+
inspect = (await getContainerInspect(id)) as unknown as Record<string, unknown>;
3738
} catch (cause) {
3839
error = cause instanceof Error ? cause.message : "Failed to inspect container";
3940
}
4041

42+
const state = (inspect?.State as { Running?: boolean; Status?: string } | undefined) ?? {};
43+
const name =
44+
typeof inspect?.Name === "string"
45+
? inspect.Name.replace(/^\//, "")
46+
: id.substring(0, 12);
47+
const image = (inspect?.Config as { Image?: string } | undefined)?.Image ?? "unknown";
48+
const restartPolicy =
49+
(inspect?.HostConfig as { RestartPolicy?: { Name?: string } } | undefined)?.RestartPolicy
50+
?.Name ?? "no";
51+
4152
return (
4253
<div className="space-y-6">
4354
<div>
4455
<h1 className="text-2xl font-bold tracking-tight">Container Details</h1>
45-
<p className="text-muted-foreground text-sm mt-1 font-mono break-all">{id}</p>
56+
<p className="text-muted-foreground text-sm mt-1">
57+
<span className="font-mono break-all">{id}</span>
58+
</p>
4659
</div>
4760

4861
{error ? (
@@ -51,15 +64,42 @@ export default async function ContainerInspectPage({ params }: Params) {
5164
</div>
5265
) : (
5366
<>
67+
<div className="grid gap-4 md:grid-cols-4">
68+
<div className="rounded-lg border border-border bg-card p-4">
69+
<div className="text-xs text-muted-foreground">Name</div>
70+
<div className="mt-1 text-sm font-semibold text-foreground font-mono break-all">{name}</div>
71+
</div>
72+
<div className="rounded-lg border border-border bg-card p-4">
73+
<div className="text-xs text-muted-foreground">Status</div>
74+
<div className="mt-1 text-sm font-semibold text-foreground">{state.Status ?? (state.Running ? "running" : "stopped")}</div>
75+
</div>
76+
<div className="rounded-lg border border-border bg-card p-4">
77+
<div className="text-xs text-muted-foreground">Image</div>
78+
<div className="mt-1 text-sm font-semibold text-foreground font-mono break-all">{image}</div>
79+
</div>
80+
<div className="rounded-lg border border-border bg-card p-4">
81+
<div className="text-xs text-muted-foreground">Restart Policy</div>
82+
<div className="mt-1 text-sm font-semibold text-foreground">{restartPolicy}</div>
83+
</div>
84+
</div>
85+
5486
<ContainerDetailsActions
5587
id={id}
88+
isRunning={!!state.Running}
5689
canStart={canStartContainer(perms, id)}
5790
canStop={canStopContainer(perms, id)}
5891
canRestart={canRestartContainer(perms, id)}
5992
canDelete={canDeleteContainer(perms, id)}
6093
/>
6194

62-
<div className="grid gap-4 md:grid-cols-3">
95+
<ContainerEditPanel
96+
id={id}
97+
currentName={name}
98+
restartPolicy={restartPolicy}
99+
canEdit={canRestartContainer(perms, id) || canDeleteContainer(perms, id)}
100+
/>
101+
102+
<div className="grid gap-4 md:grid-cols-2">
63103
<Link
64104
href={`/containers/${id}/logs`}
65105
className={`rounded-lg border border-border bg-card p-4 text-sm transition ${canViewLogs(perms, id) ? "hover:bg-accent" : "opacity-50 pointer-events-none"}`}
@@ -74,15 +114,7 @@ export default async function ContainerInspectPage({ params }: Params) {
74114
<div className="font-semibold text-foreground">Console</div>
75115
<div className="text-muted-foreground mt-1">Interactive shell inside container</div>
76116
</Link>
77-
<div className="rounded-lg border border-border bg-card p-4 text-sm">
78-
<div className="font-semibold text-foreground">Inspect JSON</div>
79-
<div className="text-muted-foreground mt-1">Low-level metadata below</div>
80-
</div>
81117
</div>
82-
83-
<pre className="overflow-auto rounded-xl border border-border bg-card p-4 text-xs text-foreground whitespace-pre-wrap break-all">
84-
{JSON.stringify(inspect, null, 2)}
85-
</pre>
86118
</>
87119
)}
88120
</div>

src/app/api/docker/containers/[id]/route.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
restartContainer,
88
removeContainer,
99
getContainerLogs,
10+
renameContainer,
11+
updateContainerConfig,
1012
} from "@/lib/docker";
1113
import {
1214
canAccessDocker,
@@ -152,3 +154,53 @@ export async function DELETE(req: NextRequest, { params }: Params) {
152154
return NextResponse.json({ error: String(err) }, { status: 500 });
153155
}
154156
}
157+
158+
// ── PATCH /api/docker/containers/[id] — edit metadata/config ─────────────────
159+
160+
export async function PATCH(req: NextRequest, { params }: Params) {
161+
const { id } = await params;
162+
const user = await getCurrentUser();
163+
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
164+
165+
const perms = user.permissions as FullPermissions | null;
166+
if (!canAccessDocker(perms) || !canViewContainer(perms, id)) return deny();
167+
168+
const body = await req.json();
169+
const action = String(body.action ?? "");
170+
171+
try {
172+
if (action === "rename") {
173+
if (!canRestartContainer(perms, id) && !canDeleteContainer(perms, id)) return deny();
174+
const name = String(body.name ?? "").trim();
175+
if (!name.match(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,127}$/)) {
176+
return NextResponse.json({ error: "Invalid container name" }, { status: 400 });
177+
}
178+
await renameContainer(id, name);
179+
} else if (action === "restart-policy") {
180+
if (!canRestartContainer(perms, id)) return deny();
181+
const policy = String(body.policy ?? "");
182+
if (!["no", "always", "unless-stopped", "on-failure"].includes(policy)) {
183+
return NextResponse.json({ error: "Invalid restart policy" }, { status: 400 });
184+
}
185+
await updateContainerConfig(id, {
186+
restartPolicyName: policy as "no" | "always" | "unless-stopped" | "on-failure",
187+
restartPolicyMaximumRetryCount: parseInt(String(body.maximumRetryCount ?? 0), 10) || 0,
188+
});
189+
} else {
190+
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
191+
}
192+
193+
await writeAuditLog(
194+
{ userId: user.id, username: user.username, role: user.role, sessionId: "" },
195+
"EDIT_CONTAINER",
196+
`container:${id}`,
197+
action,
198+
true,
199+
req
200+
);
201+
202+
return NextResponse.json({ success: true });
203+
} catch (err) {
204+
return NextResponse.json({ error: String(err) }, { status: 500 });
205+
}
206+
}

src/components/docker/ContainerDetailsActions.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Play, Square, RotateCcw, Trash2 } from "lucide-react";
66

77
type Props = {
88
id: string;
9+
isRunning: boolean;
910
canStart: boolean;
1011
canStop: boolean;
1112
canRestart: boolean;
@@ -25,7 +26,7 @@ async function runAction(id: string, action: string) {
2526
}
2627
}
2728

28-
export function ContainerDetailsActions({ id, canStart, canStop, canRestart, canDelete }: Props) {
29+
export function ContainerDetailsActions({ id, isRunning, canStart, canStop, canRestart, canDelete }: Props) {
2930
const router = useRouter();
3031
const [isPending, startTransition] = useTransition();
3132
const [error, setError] = useState<string | null>(null);
@@ -43,7 +44,7 @@ export function ContainerDetailsActions({ id, canStart, canStop, canRestart, can
4344
return (
4445
<div className="space-y-3">
4546
<div className="flex flex-wrap items-center gap-2">
46-
{canStart && (
47+
{!isRunning && canStart && (
4748
<button
4849
onClick={() => void onAction("start")}
4950
disabled={isPending}
@@ -52,7 +53,7 @@ export function ContainerDetailsActions({ id, canStart, canStop, canRestart, can
5253
<span className="inline-flex items-center gap-1"><Play className="w-4 h-4" />Start</span>
5354
</button>
5455
)}
55-
{canStop && (
56+
{isRunning && canStop && (
5657
<button
5758
onClick={() => void onAction("stop")}
5859
disabled={isPending}

0 commit comments

Comments
 (0)