+
+
+ Run command
+
+
+ {targets.length === 0 ? (
+
+ No ready, running container is available on an online server.
+
+ ) : (
+ setDeploymentId(event.target.value)}
+ >
+ {targets.map((target) => (
+
+ {target.server?.name} · {target.containerId?.slice(0, 12)}
+
+ ))}
+
+ )}
+
+
+
+
+ Command history
+ {isLoading ? (
+ Loading history…
+ ) : historyError ? (
+
+
+
+
+ Unable to load command history
+
+ Command history is unavailable or you do not have permission to
+ view it.
+
+
+
+ ) : history.length === 0 ? (
+
+
+
+
+ No commands yet
+
+ Run a command to see its output and execution details here.
+
+
+ ) : (
+
+ {history.map((item) => (
+ -
+
+
+
+
+ $ {item.command}
+
+
+
+
+
+ {item.actor.name} · {item.serverName} ·{" "}
+ {item.containerId.slice(0, 12)}
+
+
+ {formatRelativeTime(item.createdAt)}
+
+ {item.exitCode !== null ? (
+ Exit: {item.exitCode}
+ ) : null}
+ {item.startedAt ? (
+
+ Duration:
+ {formatElapsedDurationBetween(
+ item.startedAt,
+ item.completedAt,
+ )}
+
+ ) : null}
+
+
+ {item.output || item.errorMessage ? (
+
+
+ Combined output
+
+
+ {item.output}
+ {item.errorMessage
+ ? `${item.output ? "\n" : ""}${item.errorMessage}`
+ : null}
+
+ {item.outputTruncated ? (
+
+ Output was truncated at 64 KiB.
+
+ ) : null}
+
+ ) : null}
+
+ ))}
+
+ )}
+
+ {hasMore ? (
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/web/app/api/inngest/route.ts b/web/app/api/inngest/route.ts
index e1e47380..99212b54 100644
--- a/web/app/api/inngest/route.ts
+++ b/web/app/api/inngest/route.ts
@@ -23,6 +23,7 @@ import {
scheduledBackupsCheck,
scheduledDeploymentsCheck,
serviceDeletionWorkflow,
+ serviceCommandRetention,
serviceRestoreWorkflow,
staleItemsCleanup,
staleServerCheck,
@@ -35,6 +36,7 @@ export const { GET, POST, PUT } = serve({
rolloutWorkflow,
onDeploymentFailed,
staleServerCheck,
+ serviceCommandRetention,
scheduledDeploymentsCheck,
certificateRenewal,
challengeCleanup,
diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts
index a9941f38..cb44107a 100644
--- a/web/app/api/projects/[id]/services/route.ts
+++ b/web/app/api/projects/[id]/services/route.ts
@@ -329,7 +329,11 @@ export async function GET(
.from(deploymentPorts)
.where(eq(deploymentPorts.deploymentId, deployment.id)),
db
- .select({ name: servers.name, wireguardIp: servers.wireguardIp })
+ .select({
+ name: servers.name,
+ wireguardIp: servers.wireguardIp,
+ status: servers.status,
+ })
.from(servers)
.where(eq(servers.id, deployment.serverId))
.then((r) => r[0]),
diff --git a/web/app/api/services/[id]/commands/route.ts b/web/app/api/services/[id]/commands/route.ts
new file mode 100644
index 00000000..96ea24f7
--- /dev/null
+++ b/web/app/api/services/[id]/commands/route.ts
@@ -0,0 +1,178 @@
+import { randomUUID } from "node:crypto";
+import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm";
+import { db } from "@/db";
+import { deployments, servers, serviceCommands, services } from "@/db/schema";
+import { requireRequestDeveloperRole } from "@/lib/api-auth";
+import { observedReadyPhases } from "@/lib/deployment-status";
+import {
+ decodeTimestampCursor,
+ encodeTimestampCursor,
+} from "@/lib/public-api-pagination";
+import { enqueueWork } from "@/lib/work-queue";
+
+const PAGE_SIZE = 25;
+const MAX_COMMAND_LENGTH = 4096;
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const auth = await requireRequestDeveloperRole(request);
+ if (!auth.ok) return auth.response;
+
+ const { id: serviceId } = await params;
+ const cursorValue = new URL(request.url).searchParams.get("cursor");
+ const cursor = decodeTimestampCursor(cursorValue);
+ if (cursorValue && !cursor) {
+ return Response.json({ error: "Invalid cursor" }, { status: 400 });
+ }
+
+ const rows = await db
+ .select({
+ id: serviceCommands.id,
+ command: serviceCommands.command,
+ status: serviceCommands.status,
+ output: serviceCommands.output,
+ exitCode: serviceCommands.exitCode,
+ outputTruncated: serviceCommands.outputTruncated,
+ errorMessage: serviceCommands.errorMessage,
+ actor: serviceCommands.actor,
+ serverName: serviceCommands.serverName,
+ containerId: serviceCommands.containerId,
+ createdAt: serviceCommands.createdAt,
+ cursorCreatedAt: sql